Skip to main content
Version: 3.x

Product transfer (export / import)

Version
3.x
Updated
View markdown

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:

KeyWhat it is
treethe record graph, as KenedoModel::getExportData() produces it
calculationsthe calculations the product uses, with their type-specific payload
ownedIdswhich record IDs the package contains, per model — this is what classifies references
externalReferencesevery reference pointing out of the package, with what would identify it elsewhere
filePathsthe 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:

HookQuestion
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 ownedIds exists.

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().

ModeWhat it does
newevery record gets a new ID; the package is added alongside what is already there
exactrecords 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:

EngineQuestion functions
CB4 vocabularyQuestionSelection, QuestionPrice, QuestionPriceRecurring, QuestionProperty
pre-CB4 vocabularyElementEntry, 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:

  • QuestionProperty is 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 IDWhere
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 columnsinsertCalculationPayload → now nulled, set in pass two
The customer group inside a price / calculation overrideremapOverrides → 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() returns true, not an ID — on an insert it puts the new key on the data object. Reading the return value as an ID gives true, which travels into the mapping and is written into foreign keys as 1.
  • 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.

LevelMeaning
blockercannot proceed, and no tick makes it safe
warningthe operator's decision, acknowledged once per kind
noticeworth 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_id is ON 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:

SurfaceBlockersWarningsNotices
The screenrefusea tick per kind, compared by kind on the servershown
CLIrefusethe prompt interactively; --force non-interactively, else it refuses with exit 1printed
MCPrefuseacknowledge_warnings: true, and the refusal lists what would be acceptedreturned

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:

WhereLevelWhat is in it
custom_product_transfer.logalwaysWhat 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.logalwaysThe 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.logdebug mode onlyThe 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

GapWhere
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:

TableReachedHow
configbox_pages, configbox_questionsyeschild-model chain
configbox_answersyesthe questions' assignments childentries property
configbox_product_detail_panesyesthe product's product_detail_panes childentries property
a customization's child tableyes, unless told otherwiseany childentries on the product or below it — see the note under this table
configbox_calculations + _codes / _formulas / _matrices / _matrices_datayescollected via calculation properties, transitively
configbox_xref_list_productyesthe product_list_ids multiselect, resolved by name
configbox_stringsyestranslatable properties (26 of them across the graph)
configbox_cart_positions, configbox_cart_position_configurationsno, by designlive customer carts
configbox_reviewsno, by designcustomer content
configbox_chat_*no, by designvisitor conversations and analytics over them
configbox_examplesno, by designabandoned work in progress (confirmed 2026-08-05) — nothing points at it, and nothing should
configbox_oldlabelsno, by designthe 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:

HandlingCountExamples
plain column118published, price, question_type
layout only (no stored value)62group starts/ends, notes
translatable26title, description, pricelabel
calculation key8calcmodel, calcmodel_recurring, calcmodel_id_min_val
file / image7prod_image, el_image, option_image, heading_icon_filename
override JSON6price_overrides, price_calculation_overrides
join6taxclass_id, page_id, question_id
ID5id
rule JSON2rules on questions and answers
pseudo join2layoutname on products and pages
childentries2product_detail_panes, assignments
multi reference1product_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:

CaseWhat happens
The same package imported twice in new modeBoth 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 targetWarning, 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 missingBlocker. Create the record first.
Package written by a newer format versionBlocker naming both versions.
The file is not a zip, or a zip with no manifestRefused as a validation error with a plain message, not a crash.
exact onto an existing product, with an extra question a live cart referencesOverwrite 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 prunedBlocker 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:

  1. 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.
  2. 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.
  3. 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.

  • 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.ts and tests/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.