# Product transfer (export / import)

> > Scope: moving a product between ConfigBox installations — the package format, how the export > decides what belongs to a product, the two import modes and …

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/technical/product_transfer. Last updated 2026-08-25.

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

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

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

| 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_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](https://docs.configbox.at/docs/technical/cli_commands)) and over MCP (`cbx_export_product`,
`cbx_check_product_package`, `cbx_import_product_package`, see
[MCP server §3d](https://docs.configbox.at/docs/technical/mcp_server)). 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:

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:

```bash
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.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](https://docs.configbox.at/docs/technical/cli_commands) §1.13 and [MCP server](https://docs.configbox.at/docs/technical/mcp_server) §3d
  — the two non-browser surfaces.
- [Calculation engine](https://docs.configbox.at/docs/technical/calculation_engine) — what the exported calculations mean.
- [Kenedo database](https://docs.configbox.at/docs/technical/kenedo_database) — the savepoint nesting the job runner relies on.
