Product transfer (export / import)
- Version
- 4.0 preview
- Updated
Scope: moving a product between ConfigBox installations — the package format, how the export decides what belongs to a product, the two import modes and how references are re-aimed, what is validated before anything is written, and the polled job that carries a multi-product import across requests · Last reviewed: 2026-08-04
A product is not one record. It is a product row, its configurator pages, their questions, those questions' answers, the product's detail panes, every calculation any of them uses, the rules wired through all of it, and the images and files hanging off any of those records. Exporting "the product" means all of that; importing it means putting all of it back somewhere the IDs are already taken. That second half is where the work is.
The admin screen is Product Transfer (controller=adminproducttransfer). All paths below are
relative to the component root.
1. The package
A zip, because the files have to travel with the records; JSON inside, because a package outlives the release that wrote it and someone will eventually need to read one.
manifest.json what is in here, and which CBX built it
products/<id>.json one product's whole graph, keyed by its ID ON THE SOURCE
files/<path> the images and files, under their data-store-relative paths
manifest.json carries the format version, when it was exported, the source platform, and both
CBX version numbers — latest_update_version and latest_customization_update_version from
#__configbox_system_vars.
Those come from system_vars rather than from the component's file version deliberately: a package cares about the shape of the tables it is going into, and an install with a customization layer has columns and models the exporting install may not have had.
Each products/<id>.json holds:
| Key | What it is |
|---|---|
tree | the record graph, as KenedoModel::getExportData() produces it |
calculations | the calculations the product uses, with their type-specific payload |
ownedIds | which record IDs the package contains, per model — this is what classifies references |
externalReferences | every reference pointing out of the package, with what would identify it elsewhere |
filePaths | the files to carry, relative to the data store |
2. The export walks properties, not tables
ConfigboxProductTransferHelper::exportProducts().
KenedoModel::getProperties() already merges whatever model_property_customization/ adds on an
install. So an export driven by properties carries a site's custom fields without knowing they
exist, where a table-driven export would have to be taught about each one. Three questions are put to
every property:
| Hook | Question |
|---|---|
KenedoProperty::getExportReference() | what do you point at? |
KenedoProperty::getPropertyFilePaths() | what files do you own? |
KenedoProperty::getPropertyChildTransferItems() | what child entries hang off you? |
Calculations are the exception, because they are not reached through the record tree at all — a
question points at one through a KenedoPropertyCalculation. They are collected separately, with
their type-specific rows (calculation_formulas / _codes / _matrices / _matrices_data), and
resolved transitively: a formula can nest a Calculation() term and a matrix can have
calculation axes.
3. Three kinds of reference — the actual difficulty
Conflating these is how a transfer goes subtly wrong.
1 · Into the package. A page names its product, an answer names its question, a rule names an answer. On import these are re-aimed at the records the import just created.
2 · Out of the package. A tax class, a customer group, a product list, a shipping method. A multiselect (a product's list membership) is this kind too, and holds many at once — the export expands it into one reference per assignment, and an assignment whose target does not exist here is dropped rather than carried across as a number. These are never created by an import — the target install's own records are used. They are matched by a display name the two installs can agree on, and an unmatched one is reported rather than guessed at. Importing a product must not invent a tax class, because the rate is something the package cannot know is right.
3 · Not a reference at all. A pseudo join stores an option's own string key — a template folder name, a connector name. Nothing to re-aim: it either exists on the target or it does not.
Which kind a reference is depends on whether the referenced record is IN THE PACKAGE, not on which model it is. A product that references another product as an add-on is referencing something outside, and gets treatment 2. That is why
ownedIdsexists.
The reference descriptors come from KenedoPropertyJoin::getExportReference(), which reads the join's
modelClass, propNameKey and propNameDisplay — so a customization-added join is covered without
being listed anywhere.
4. The two import modes are one code path
ConfigboxProductTransferImportHelper::importProduct().
| Mode | What it does |
|---|---|
new | every record gets a new ID; the package is added alongside what is already there |
exact | records keep the IDs they had on the source, overwriting whatever holds them, and target records the package does not contain are removed |
They differ in exactly one thing: what ID a record gets. After that, both need the same answer to
the same question — "the source called this record 42; what is it called here?" — so the ID mapping
and the whole second pass cannot tell them apart. In exact mode the mapping is the identity, which
is deliberate rather than wasteful: there is no second, less-travelled branch to be wrong.
The mode is chosen at import time, so one package can go either way.
Two passes, because a rule on page 1 can name an answer on page 5
Nothing can be re-aimed until everything exists. So pass one creates every record and collects
oldId → newId; pass two rewrites rules, formulas, matrix axes, calculation keys and override
columns. This is the same shape as KenedoModel::copyRulesAndCalculations(), for the same reason.
The mapping is built through KenedoModel::recordCopyId() — the same call the copy path uses, so
the importer inherits its fixes instead of reimplementing them.
Why the rewriting is not ConfigboxRulesHelper::getRuleCopy()
That is the copy path, and copy semantics are wrong here. When a copy meets a reference to a calculation it makes a new copy of that calculation; an import must point at the calculation it already created from the package.
So the rewriting walks the JSON itself, blind, rewriting by key name wherever the key appears
(questionId, calcId, and value when a sibling field or the enclosing type says it is an ID).
Two reasons it is a blind walk rather than a dispatch on term type:
- The same term exists in two shapes in real data —
{"type":"QuestionProperty","questionId":413}and{"type":"QuestionProperty","data":{"questionId":413}}. Code reading$term['questionId']sees only the first. - An install may add its own condition and term types. A dispatching walk would hand those to the copy path.
A code calculation names questions by FUNCTION NAME, in two vocabularies
The blind JSON walk does not reach a code calculation: its code is a plain string column, and the
question IDs in it are function arguments. Those are rewritten by name, from a single list —
ConfigboxModelCalcCodes::QUESTION_ID_FUNCTIONS, which the transfer import calls rather than keeping
its own copy.
The list has to carry both vocabularies, and for a long time it carried the wrong one. The engine ships in two variants and the component serves both:
| Engine | Question functions |
|---|---|
| CB4 vocabulary | QuestionSelection, QuestionPrice, QuestionPriceRecurring, QuestionProperty |
| pre-CB4 vocabulary | ElementEntry, ElementPrice, ElementPriceRecurring |
Both remappers listed the pre-CB4 names only — which this engine has no evaluator for at all — so every CB4 code calculation survived a copy and a transfer still naming the questions of the install it came from. Silent, because a code string is text with no foreign key to refuse it: on the same site those IDs exist, and on a target the token resolves against a question that is not there and prices as 0.
Two rules for anything added to that list:
QuestionPropertyis not like the others. Its argument is<questionId>.<fieldPath>, optionally with a default —QuestionProperty(413.selectedAnswer.price, '0'). Only the leading ID may be rewritten; rewriting the whole argument is as wrong as rewriting none of it.RegardingQuestion()must never be in it. Its argument is a field path, not an ID — the question it refers to is the one being calculated for.
Calculation keys are written in pass two, not pass one
The package's value for calcmodel and friends is the calculation's ID on the source, and those
columns are real foreign keys. Nothing has created the calculations when a question is stored -
questions are created before importCalculations() runs - so writing the source's ID there is a key
to a row that does not exist, and MySQL refuses it.
A round-trip test cannot see this, which is worth knowing before trusting one: export and import
on the same site and the source's calculation IDs happen to exist, so the constraint is satisfied by
accident. It takes a target that does not already have those calculations - which is every real
transfer - and it was never specific to exact mode.
So clearCalculationKeys() empties them before the first write and pass two fills them in from the
package. Rules and override columns are deliberately not cleared: text and JSON, no foreign key.
The rule this keeps re-teaching: a round trip on one site is not a transfer
Four separate bugs in this feature had the same shape — a foreign key or an external reference carrying the source install's ID — and none of them is visible when export and import happen on the same site, because there the source's IDs happen to exist and the constraint is satisfied by accident. Each was found only by importing into a second install:
| What carried a source ID | Where |
|---|---|
A record's calculation keys (calcmodel and friends) | importNode → now cleared, set in pass two |
| A matrix's axis questions and axis calculations; a code calculation's question columns | insertCalculationPayload → now nulled, set in pass two |
| The customer group inside a price / calculation override | remapOverrides → resolved references threaded into pass two |
| A multiselect's assignments (a product's lists) | KenedoPropertyMultiselect::getExportReference → now reported and resolved by name |
So test a transfer against a second install with a divergent ID space. tools/setup-transfer-fixture.php
builds a product carrying every one of these, and the two sites should have their tax class, customer
group and product list at different IDs — matching names, different numbers — or the resolution
step proves nothing.
Traps this code was taught by the demo catalogue
KenedoModel::store()returnstrue, not an ID — on an insert it puts the new key on the data object. Reading the return value as an ID givestrue, which travels into the mapping and is written into foreign keys as1.- Matrix coordinates are part of the primary key
(id, x, y), so remapping them row by row collides with rows not yet remapped. The set is rewritten wholesale. - A matrix coordinate is only an answer ID when its axis question HAS answers. An axis that takes a
typed value stores the customer's own numbers, and remapping those turns "1400 mm" into whatever
answer holds ID 1400.
row_type == 'question'does not distinguish the two; only the presence of answers does.
5. Validation runs before anything is written
ConfigboxProductTransferValidationHelper::validatePackage(). It reports and changes nothing — after
the first write the report no longer describes the install.
| Level | Meaning |
|---|---|
| blocker | cannot proceed, and no tick makes it safe |
| warning | the operator's decision, acknowledged once per kind |
| notice | worth saying, needs no decision |
Warnings are acknowledged per kind rather than per row: forty answers referring to one missing tax class is one decision, and forty checkboxes would train the operator to tick without reading.
The two exact-mode dangers fail in opposite directions
This is why they are not one list:
- Removing a question that a live cart references succeeds and takes the cart rows with it —
cart_position_configurations.question_idisON DELETE CASCADE. A warning about irreversible data loss. - Removing one a calculation matrix or code uses fails — those foreign keys are
NO ACTION. A blocker, naming the calculation so the operator knows what to point elsewhere.
Pruning itself runs deep to shallow — answers, then questions, then pages — because
questions.page_id → pages is NO ACTION and a page cannot go before its questions do.
The version check
A core or customization version mismatch is always a warning, never a blocker. Two installs on different versions very often exchange products perfectly well; what the operator needs is to know beforehand rather than afterwards.
6. The polled import job
ConfigboxProductTransferJobHelper, table #__configbox_product_import_jobs (migration 3.8.5).
Importing a product measures at about 0.25 s, so a product fits comfortably inside one request and
one transaction. A package of forty does not — that would sit on one request long enough to meet a
CDN's timeout, and a timeout mid-import is the thing an import must never do. So the work is broken at
product boundaries: importNext does one product per request and the client polls.
Self-healing, the same way the charset fixer is: every step derives what is left from the database, never from anything the client sent. A request that times out is harmless — PHP carries on server-side and finishes the product, and the next poll sees it done.
The one window that would be dangerous is the product committing and the process then dying before
the job row records it — the next poll would import it again, which in new mode means a silent
duplicate. So runNextStep() opens a transaction around both the import and the progress update.
KenedoDatabase nests through savepoints, so the import's own transaction becomes a savepoint inside
this one and the two commit together or not at all.
Jobs are keyed by an unguessable token rather than a sequential ID, so one administrator cannot
poll or advance another's import. A finished job takes its uploaded package with it, and
cleanUp() — called when the screen is opened, since CBX has no scheduler — removes anything older
than 24 hours.
7. The screen
controllers/adminproducttransfer.php, view adminproducttransfer, client
assets/javascript/producttransfer.js (driving the reusable configbox/poller).
Export goes as a real form submission, not XHR — the answer is a file download and XHR cannot hand the browser one.
Import is a three-step conversation and only the last step writes anything: checkPackage reads
and validates, the review step shows the findings and collects acknowledgements, startImport records
the job, and importNext runs the loop.
startImport re-validates rather than trusting what the browser was shown — the screen's button is
a convenience, a blocker must stop an import whatever the client believes, and the install may have
changed since the check. Acknowledgements are compared by kind for the same reason. Mode defaults
to new, so a request that fails to say which it wants gets the one that creates rather than the one
that overwrites and prunes.
7b. The other two ways in, and the one rule they share
The same helpers are reached from the CLI (configbox:product:export / configbox:product:import, see
CLI commands §1.13) and over MCP (cbx_export_product,
cbx_check_product_package, cbx_import_product_package, see
MCP server §3d). All three surfaces re-validate before writing and none
of them can accept a blocker.
Where they differ is only in how a warning gets accepted, because a warning is the operator's decision and each surface has a different operator:
| Surface | Blockers | Warnings | Notices |
|---|---|---|---|
| The screen | refuse | a tick per kind, compared by kind on the server | shown |
| CLI | refuse | the prompt interactively; --force non-interactively, else it refuses with exit 1 | printed |
| MCP | refuse | acknowledge_warnings: true, and the refusal lists what would be accepted | returned |
Three surfaces disagreeing about when an import is allowed would be worse than any one of them being
wrong, so the asymmetry is deliberate and identical everywhere: no answer means no. An exact-mode
import deletes records the package lacks, and deleting a question takes any saved cart configuration
referring to it — so a deploy step that has not said --force, or a model that has shown nobody the
warnings, must get a refusal rather than a silent overwrite.
The id-clash blocker and the --new-ids / new_ids offer (§4) work the same way on all three.
7c. What it logs, and what a failure tells you
Three channels, because they answer different questions:
| Where | Level | What is in it |
|---|---|---|
custom_product_transfer.log | always | What this feature adjusted or refused: a blocked check with its blockers, a URL segment made unique, a file that was referenced but missing, an import that failed and was rolled back, a job that failed and how much of the package it had already committed. |
configbox_errors.log | always | The diagnostic detail of a system failure — the query, the file, the line, the trace — found by the identifier the exception's message carries. KenedoSystemException::logged() puts it here rather than in the message on purpose: a response may be read by a shop's customer. |
configbox_debug.log | debug mode only | The step-by-step trace. One line per record (Product import: Answers 118 -> 4711), the package's counts on export, the prune counts, the pass boundaries, the commit, and one line per polled job step naming the package and files directory and whether they are still there. Grep Product import: / Product export:. |
ConfigboxProductTransferHelper::trace() / ::logProblem() / ::getLogHint() are the three entry
points; getLogHint() resolves the directory through the platform, because the four hosts keep their
logs in four places and a hardcoded path would be right on one of them.
A failure names the record, not just the product. Every level of the import adds itself to the
exception on the way up (KenedoException::addContext()), so what reaches the surfaces is
import of product 20931 in exact mode -> import of Pages ID 71 -> import of Questions ID 402 -> import of Answers ID 118 -> …. That is what makes a store failure four levels down actionable; without it the
reason arrived naming the product only, which on a 40-record product is not a location.
A caller-fixable failure is a KenedoValidationException and says so in the message. An export target
that cannot be written is the clearest case: the caller named the path, so the message names the cause
("Its directory … exists but is not writable by the web/CLI user (www-data)"). It was a system exception
once, which meant an operator who mistyped -o got an apology and a log identifier. The system type is
for what the caller cannot fix.
A half-landed package is reported as such. Products import one transaction each, so a multi-product
package that fails on product three has committed two. Both non-browser surfaces say how many landed,
name them, and say that re-running in exact mode is safe while new would add second copies.
Forced-failure harness: tools/check-transfer-failure-messages.sh (with tools/make-transfer-faults.php
and tools/fault-trigger.php) in the cbx-joomla project runs every failure through both surfaces and
prints the answers. Two of them are pinned in tests/specs/cli/product-transfer-cli.spec.ts.
8. Known gaps
| Gap | Where |
|---|---|
Old URL segments are not transferred, deliberately: #__configbox_oldlabels is the source site's SEF redirect history, and those URLs were never live on the target. Noted so it is not mistaken for an oversight. | — |
extractFiles() and its zip path-traversal guard are exercised by the round-trip but no test asserts a file landed on disk and was attached to the imported record. | ConfigboxProductTransferHelper::extractFiles() |
getParentForeignKey() is a fixed list of four models — correct for everything a product import creates today, but a customization-added childentries relation would get NULL and land its records without a parent key. | ConfigboxProductTransferImportHelper::getParentForeignKey() |
9. What the graph reaches — audited
Every table carrying a product-side foreign key, and what the transfer does with it:
| Table | Reached | How |
|---|---|---|
configbox_pages, configbox_questions | yes | child-model chain |
configbox_answers | yes | the questions' assignments childentries property |
configbox_product_detail_panes | yes | the product's product_detail_panes childentries property |
| a customization's child table | yes, unless told otherwise | any childentries on the product or below it — see the note under this table |
configbox_calculations + _codes / _formulas / _matrices / _matrices_data | yes | collected via calculation properties, transitively |
configbox_xref_list_product | yes | the product_list_ids multiselect, resolved by name |
configbox_strings | yes | translatable properties (26 of them across the graph) |
configbox_cart_positions, configbox_cart_position_configurations | no, by design | live customer carts |
configbox_reviews | no, by design | customer content |
configbox_chat_* | no, by design | visitor conversations and analytics over them |
configbox_examples | no, by design | abandoned work in progress (confirmed 2026-08-05) — nothing points at it, and nothing should |
configbox_oldlabels | no, by design | the source's SEF history |
A customization's childentries is in the package by default, and there are two ways it is not.
'exportWithParent' => false on the property keeps those records out — for rows that belong with the
parent but whose meaning is local to this install: a log, a usage history. Separately, a property that
names no parent relation (the plain embedded list — no viewFilters, no foreignKeyField, a whole
list view parked on a tab of a form) is refused outright: its "children" are every record of the child
model, so a package built from a parent carrying one used to contain the entire salutations, currency
or tax-class table, and an import would have written them into the target. That is now impossible by
construction rather than by remembering to opt out.
@see property-types/childentries.md — the same two questions govern copy() and the delete cascade
Property coverage across the five exported models — 245 properties in total, every one accounted for:
| Handling | Count | Examples |
|---|---|---|
| plain column | 118 | published, price, question_type |
| layout only (no stored value) | 62 | group starts/ends, notes |
| translatable | 26 | title, description, pricelabel |
| calculation key | 8 | calcmodel, calcmodel_recurring, calcmodel_id_min_val |
| file / image | 7 | prod_image, el_image, option_image, heading_icon_filename |
| override JSON | 6 | price_overrides, price_calculation_overrides |
| join | 6 | taxclass_id, page_id, question_id |
| ID | 5 | id |
| rule JSON | 2 | rules on questions and answers |
| pseudo join | 2 | layoutname on products and pages |
| childentries | 2 | product_detail_panes, assignments |
| multi reference | 1 | product_list_ids |
10. Adversarial cases, and what happens
Exercised by tools/check-transfer-scenarios.php against a scratch install, and gated by
tests/specs/backend/product-transfer-scenarios.spec.ts, which seeds the fixture, exports it, runs
the script with --json and asserts each check individually. The script's exit code is part of the
contract — 0 every check passed, 1 a check failed, 2 the run could not be set up:
| Case | What happens |
|---|---|
The same package imported twice in new mode | Both land. The second product's URL segment is made unique (…-2), because store() applies no model-level validation and two products cannot share one URL. SKUs are left alone — they are not unique in this schema. |
| A referenced record is missing on the target | Warning, acknowledgeable, and the field is cleared — never left holding the source's ID, which would point at whatever holds that number here. |
| A required reference is missing | Blocker. Create the record first. |
| Package written by a newer format version | Blocker naming both versions. |
| The file is not a zip, or a zip with no manifest | Refused as a validation error with a plain message, not a crash. |
exact onto an existing product, with an extra question a live cart references | Overwrite warned, cart data loss warned with the row count, both acknowledgeable; on proceed the question is pruned and the cart rows go with it, as the warning said. |
| A question a calculation matrix uses as an axis would be pruned | Blocker naming the calculation — that FK does not cascade, so the delete would fail. |
11. Testing a transfer without a second install
The rule in §4 — a round trip on one site is not a transfer — used to mean the only real check
needed a second install, and that is precisely why it stayed manual and stopped being run.
tools/diverge-transfer-ids.php makes ONE install look like a different one to a given package:
- Every record the package points at from outside is renamed, and a same-named replacement is created at a new id. The name now resolves to a different id, while the source's id stays alive under the aside name — so the wrong answer remains available and "the import picked the right record" is a real assertion rather than a vacuous one.
- The package's source product is deleted, so its page, question, answer and calculation ids are genuinely absent and a foreign key written with one fails the way it would on a real target.
- Every table's next id is pushed into a band of its own. This is the part worth copying if you write a similar check elsewhere: pushing them all past one floor is the obvious thing and it hides the most interesting failure, because with questions, answers and calculations starting from the same number a matrix axis holding a calculation id where a question id belongs still points at a row that exists.
The whole procedure, by hand:
php tools/setup-transfer-fixture.php
php tools/export-product-package.php --sku=XFER-COMPLEX -o /tmp/x.zip
php tools/diverge-transfer-ids.php /tmp/x.zip
php tools/import-product-package.php /tmp/x.zip --force
It destroys data on the site it runs against — point it at a worktree slot. A genuine two-install run
is still the gold standard and the tools support it (export on A, import-product-package.php on B);
what this replaces is the reason the check never ran.
12. Related
tests/specs/backend/product-transfer.spec.ts— drives the screen end to end and pins the reference remapping.tests/specs/backend/product-transfer-divergent-ids.spec.ts— the cross-site check, automated: the divergence above, then every reference asserted and the imported product priced against the source.tests/specs/backend/product-transfer-scenarios.spec.ts— the adversarial cases in §10.tests/specs/backend/product-transfer-full-product.spec.ts— a product carrying every feature there is, exported and imported into a cloned database with a diverged id space; also the guard that the fixture still carries all of it.tests/specs/cli/product-transfer-cli.spec.tsandtests/specs/api/mcp-product-transfer.spec.ts— the §7b refusals on the other two surfaces, each asserting the refusal changed nothing.- CLI commands §1.13 and MCP server §3d — the two non-browser surfaces.
- Calculation engine — what the exported calculations mean.
- Kenedo database — the savepoint nesting the job runner relies on.