# Deep dive: the option/answer collapse (xref → answer)

> Audience: a developer — or an AI instructed to make an extensively-customized CBX site compatible with CBX 4. This is the exhaustive reference behind the sum…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive. Last updated 2026-08-01.

---
**Audience:** a developer — or an AI instructed to make an extensively-customized CBX site compatible
with CBX 4. This is the exhaustive reference behind the summary in the
[breaking-changes log](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/breaking-changes-log) and the mechanical recipe in
[model-property-customizations.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/model-property-customizations). Read this once before touching any
customization that mentions options, xrefs, `option_id`, `xref_id`, or answer titles/prices.

> If you only need to fix `model_property_customization/` files, the mechanical recipe in
> [model-property-customizations.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/model-property-customizations) is enough. Come here when custom
> code touches the **data model, SQL, caches, orders, pricing, or strings** around answers.

> ✅ **Migration status — the automatic data collapse SHIPS as `helpers/updates/3.5.3.php`.** It runs
> during the package install/upgrade itself (the component's `postflight` applies outstanding updates), so
> by the time you open the site the collapse has happened. It creates `#__configbox_answers`, folds the
> option columns onto the answer rows, fans shared options out into independent answers, re-keys
> `#__configbox_strings` **and** the frozen `cbcheckout_order_strings` from `option_id` to `answer_id`,
> renames `cbcheckout_order_configurations.xref_id` → `answer_id` (dropping its `option_id`), and drops
> `#__configbox_options` + `#__configbox_xref_element_option`. Where sections below say the core "migrates"
> / "re-keys" / "fans out", that is what `3.5.3.php` does. `3.6.2.php` then backfills the
> `answers.option_id` breadcrumb (§3.8) onto databases collapsed before that column existed.
>
> **What it does NOT do: your customization's own tables.** It drops any foreign key that pointed at
> `#__configbox_options` and leaves that table's rows exactly as they are — see §3.8 for the hand-over and
> the re-link recipe.

---

## 1. The old model (CBX ≤ 3) — what you must un-learn

An "answer" a customer could pick was represented by **two** tables:

- **`#__configbox_options`** — the *"global option"*: a **reusable, shared** answer definition. It held the
  shared attributes — `sku`, `price`, `price_overrides`, `price_recurring(_overrides)`, `weight`,
  `was_price(_recurring)`, `option_custom_1..4`, availability, image — and its **title/description** lived
  in `#__configbox_strings` keyed by the **option id** (langType 5 = title, 15 = description, 60/61 =
  custom). One option row could be **assigned to many questions**.
- **`#__configbox_xref_element_option`** — the *"assignment"* / *"xref"*: the per-question row that placed a
  given option into a specific question (`element_id` + `option_id`), and carried the **question-specific
  overrides** — rules, calculation models, per-answer price-calc overrides, visualization image, picker
  image, ordering, published flag, `internal_name`, assignment custom fields, shapediver value.

At **cache-build time** CBX merged the option's columns onto the xref record, so downstream code saw
one flat "answer" object. Two ids therefore existed for every answer:

- the **xref id** = the identity of *this answer in this question* (used in cart selections, orders, rules,
  pricing — everywhere a selection is referenced);
- the **option id** = the identity of the *shared definition* (used only to reach the shared columns and
  the title/description strings).

The single most important fact: **the xref id was already the answer's identity everywhere.** The option
id was only ever a pointer to shared attributes + strings.

## 2. The new model (CBX 4) — one table

The two tables are collapsed into **`#__configbox_answers`**, a clean 1:n from questions:

- **`answers.id` == the old xref id.** Preserved exactly. So every stored selection, order line, and rule
  reference keeps resolving with no data migration of those references.
- The option columns are **folded onto the answer row** (sku, price(+overrides), was_price(_recurring),
  weight, availability, image, option_custom_1..4, desc_display_method).
- Title/description/custom strings (`#__configbox_strings` types **5/15/60/61**) are **re-keyed from the
  option id to the answer id**. A shared option used by N answers was **fanned out** into N string rows.
- A formerly-shared option becomes **N independent answer rows** (one per question that used it). They no
  longer share anything — editing one does not change the others. This is the whole point: **no more
  "global option" reuse.**
- `#__configbox_options` and `#__configbox_xref_element_option` are **dropped**. The `option_id` column and
  the `option_id` "Reused Answer" join are **gone**.

### The mental-model swap

| Think in old terms… | …now think |
|---|---|
| "an option, reusable across questions" | "an answer, owned by exactly one question" |
| "the xref that assigns option O to question Q" | "the answer" (there is no separate assignment) |
| "option_id → shared columns / strings" | those columns/strings live **on the answer**, keyed by `answer.id` |
| "xref_id = the selection identity" | "answer.id = the selection identity" (same value, new name) |
| two ids per answer | **one** id per answer |

## 3. Every touch-point (find-and-fix checklist)

Grep the customization for each pattern below. For each hit, apply the mapping.

### 3.1 Table names (raw SQL)
- `#__configbox_xref_element_option` → `#__configbox_answers`.
- `#__configbox_options` → **gone**. Its columns are now on `#__configbox_answers`. A query that JOINed
  options to xref becomes a plain read from `#__configbox_answers`; select the (formerly option) column
  directly off the answer.
- **Hard-coded prefixes evade grep.** Some custom code writes the real prefix (e.g. `e5xae_configbox_options`
  instead of `#__configbox_options`). Grep for the bare table stem (`configbox_options`,
  `xref_element_option`, `xref_listing`) as well as the `#__` form.

### 3.2 The `option_id` column / concept
- `option_id` as a **join key** (options↔xref) → the join is gone; read the column off the answer.
- `option_id` as the **strings key** (title/description lookups) → use the **answer id**.
- The **`option_id` join property** ("Reused Answer", `type=join` → `ConfigboxModelAdminoptions`) → **remove
  it**. There is no shared option to point at. A custom join subtype like `bcoptionjoin` that re-parents an
  answer to a different option has **no meaning** now — delete it.
- `$answer->option_id`, `$configuration->option_id`, `ConfigboxAnswer::$option_id` → removed. Use
  `$answer->id`.

### 3.3 Strings (title / description / custom)
- Types **5 (title), 15 (description), 60/61 (custom 5/6)** are keyed by the **answer id** now (were
  option id).
  ```php
  // BEFORE
  ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->option_id);
  // AFTER
  ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->id);
  ```
- If your customization **writes** answer strings, key them by the answer id. Never write these directly if
  a translatable property can do it (the property's `store()` handles all active languages).

### 3.4 Model / controller / view names
| Old | New |
|---|---|
| `ConfigboxModelAdminoptions`, `...Adminoptionassignments`, `...Adminxrefelementoptions` | `ConfigboxModelAnswers` |
| `controller=adminoptions` / `adminoptionassignments` / `adminxrefelementoptions` (+ singular) | `controller=adminanswers` (`adminanswer`) |
| view `adminoption(s)`, `adminoptionassignment(s)`, `adminxrefelementoption(s)` | `adminanswer(s)` |
| `model_property_customization/{adminoptions,adminoptionassignments,adminxrefelementoptions}.php` | `adminanswers.php` |

- `KenedoModel::getModel('ConfigboxModelAdminoptions')` still resolves (aliased → `...Adminanswers`, logged
  as legacy) — but update it. `ConfigboxModelAdminoptions::class` used as a bare string also resolves via
  the alias, but the class no longer exists, so anything else touching that class name fatals.

### 3.5 Caches (only if you read them)
| Old key | New key |
|---|---|
| `xref_to_element`, `element_to_xref`, `xref_to_product`, `product_to_xref`, `xref_to_page`, `page_to_xref` | `answer_to_question`, `question_to_answer`, `answer_to_product`, `product_to_answer`, `answer_to_page`, `page_to_answer` |
| `priceByXref`, `weightByXref`, `calcModelByXref`, `priceOverridesByXref`, … (`*ByXref`) | `*ByAnswer` |
| product cache `xrefs.product_<id>` | `answers.product_<id>` |

> **Note — two renames stacked.** This collapse first produced `answer_to_element` / `element_to_answer`;
> the later **element→question rename** then renamed those to `answer_to_question` / `question_to_answer`
> (the current live keys). `answer_to_element` / `element_to_answer` still resolve but only as **legacy
> aliases** now — target `*_to_question` / `question_to_*` in new code. See
> [element-question-rename.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/element-question-rename).

### 3.6 Pricing API
- `ConfigboxPrices::getXrefPrice/getXrefPriceRecurring/getXrefWasPrice/getXrefWasPriceRecurring/getXrefWeight`
  → `getAnswerPrice/getAnswerPriceRecurring/getAnswerWasPrice/getAnswerWasPriceRecurring/getAnswerWeight`.
  (The frontend already keyed these by the answer id; only the method name changed.)

### 3.7 Orders (frozen data)
- `cbcheckout_order_configurations` used to carry both `xref_id` and `option_id`. It now carries a single
  **`answer_id`** (`xref_id` renamed, `option_id` dropped). Update custom code that reads order-config rows.
- Frozen **order strings** (`cbcheckout_order_strings`, types 5/15/28/60/61) are keyed by the answer id.
  `3.5.3` re-keys existing orders' frozen strings from `option_id` to `answer_id`, mapping through
  `order_strings.order_id → order_positions → order_configurations` (which carried both ids on the row).
  A string for a shared option fans out to one row per answer, exactly like the live strings. If your
  customization reads frozen order strings by `option_id`, switch to `answer_id`.

### 3.8 External-append tables (heavily-customized sites)
This is the subtle one, and the only place the core deliberately hands you an unfinished job.

**`#__configbox_answers.option_id` — the breadcrumb that makes this possible.** The collapse records, on
every answer, the id of the option it came from. CBX never reads it; it exists *solely* so
customization data keyed by option id can be re-linked **after** `#__configbox_options` is gone. Without
it the mapping would be unrecoverable, because `3.5.3` drops both old tables. A shared option appears as
`option_id` on each of the N answers it fanned out into — so the breadcrumb carries the fan-out for you.

> `option_id` is **NULL** on a database that was collapsed *before* the column existed (`3.6.2` adds the
> column but cannot invent the values — the xref it would need is long gone). If you see NULLs
> everywhere, that install cannot be re-linked automatically; the source data no longer exists.

- A property that stored against the **option** (`foreignTableKey => 'option_id'`, table e.g.
  `#__configbox_external_option_appends`) now hangs off the **answer**. The core's
  `normaliseLegacyCustomPropertyDefinitions()` **remaps the code key** `option_id → answer_id`
  automatically — **but it does NOT touch the table**, and `3.5.3` **drops the foreign key** that table had
  into `#__configbox_options` (logging that it did) so the options table can go. Your rows are all still
  there, untouched; their `option_id` values now point at ids that no longer exist. You must:
  1. add an **`answer_id`** column to that external table (guard with `tableFieldExists`), and
  2. **migrate the rows** via the breadcrumb — one option-append fans out to N answer-appends:
     ```sql
     INSERT INTO `#__configbox_external_option_appends_new` (`answer_id`, /* …your columns… */)
     SELECT a.`id`, /* …your columns… */
     FROM `#__configbox_external_option_appends` AS ap
     JOIN `#__configbox_answers` AS a ON a.`option_id` = ap.`option_id`;
     ```
     Append rows whose option had no answer (an option no question ever used) match nothing and are
     dropped — that is correct, they were already unreachable.
  3. Deliver it as a `data/customization/updates/` migration (see the migrations skill /
     `../technical/com_configbox_migrations.md`), and decide the **semantic** question the core cannot: if a shared
     option's appended value was per-*option* (a SKU, say), duplicating it to N answers means editing one
     no longer affects the others. That may be exactly what you want, or may need consolidating.
  4. Run and verify it via the CLI rather than waiting for a web request:
     `configbox:migrate` applies it (and names it in the output), `configbox:migrate --status` confirms
     nothing is left pending, and if your script throws, `configbox:migrate -v` prints the failing
     script + exception chain + log tail, and `configbox:migrate:unblock` lifts the freeze once you have
     fixed it. Remember core scripts run **before** customization scripts, so by the time yours executes,
     `#__configbox_options` is already gone — exactly why the breadcrumb join above is the only route.
- A property that already stored against the **answer** (`foreignTableKey => 'answer_id'`, e.g.
  `#__configbox_external_answer_appends`) needs **no table change and no data migration** — answer ids
  are the old xref ids, so its `answer_id` values stay correct. Its foreign key *constraint*, however,
  referenced the xref table and is **removed** by `3.5.3` (see the contract below). The data keeps
  matching `#__configbox_answers.id`; re-add the constraint from a customization update script if you
  want it enforced again.

> ⚠️ **FKs may get removed — the `#__configbox_external_*` constraint contract.** The migration scripts
> find inbound foreign keys via `information_schema` (no name assumptions) and apply one policy,
> stated here so nobody is surprised:
>
> - **Tables following the documented `#__configbox_external_*` convention:** any FK they hold on a
>   retiring table — `#__configbox_elements` (renamed in `3.5.2`), `#__configbox_xref_element_option`
>   and `#__configbox_options` (dropped in `3.5.3`) — is **removed by the migration, and deliberately
>   NOT re-created**. Re-adding constraints on behalf of schema the core does not own is brittle. Your
>   **data is untouched**, and the id columns keep matching where a successor exists (`element_id` →
>   `#__configbox_questions.id`; xref-referencing columns → `#__configbox_answers.id`, ids identical).
>   If you want the referential integrity back, re-add the constraint yourself from a
>   `data/customization/updates/` script, pointing at the successor table. Each removal is logged to
>   `configbox_upgrade_errors.log` with exactly that instruction.
> - **Tables with any other name:** the migration does **not touch them**. On the *renames* their FKs
>   simply follow the table to its new name (InnoDB carries inbound constraints across) and keep
>   working. On the *drops* a remaining inbound FK makes the `DROP TABLE` fail and **freezes the
>   migration** — deliberately: the site owns that constraint and must remove or re-point it itself,
>   then re-run (`configbox:migrate:unblock`, then `configbox:migrate`). The script logs which
>   constraint blocked it before attempting the drop.
>
> Name your customization side tables `#__configbox_external_*` to be inside the contract.

### 3.9 The product-copy remap bucket
- Copy pipelines remap old→new answer ids through `$copyIds['<controller>']`. The bucket key changed from
  `$copyIds['adminoptionassignments']` to `$copyIds['adminanswers']`. Update custom rule-condition types or
  copy hooks that read that bucket (e.g. a custom `getCopiedConditionData`).

### 3.10 Code that **creates** answers — the shims' one genuinely dangerous case

Everything else in this document is about code that *reads* the old model. This section is about code
that *writes* it, and it is the most dangerous item in the whole collapse — because it does not fail.

`getModel()` shims all three old model names onto `ConfigboxModelAnswers`:

```
ConfigboxModelAdminoptions           ─┐
ConfigboxModelAdminoptionassignments ─┼─→ ConfigboxModelAnswers
ConfigboxModelAdminxrefelementoptions ─┘
```

That is exactly right for reads. For the standard pre-CB4 **creation** idiom it is a trap, because the
idiom used two models on purpose — an option row, then an xref row linking it to the question:

```php
// BEFORE (pre-CB4) — two models, two tables, two rows. Correct then.
$optionModel = KenedoModel::getModel('ConfigboxModelAdminoptions');
$opt = $optionModel->initData();
$opt->title = 'Red';
$optionModel->store($opt);                       // → #__configbox_options

$xrefModel = KenedoModel::getModel('ConfigboxModelAdminxrefelementoptions');
$x = $xrefModel->initData();
$x->element_id = $questionId;
$x->option_id  = $opt->id;
$xrefModel->store($x);                           // → #__configbox_xref_element_option
```

Post-collapse both variables point at the **same** model and the **same** table, so this still runs
start to finish without a single error — and writes **two answer rows**:

- an **orphan** with no `question_id` (the "option" that no longer has a table of its own), and
- a real answer whose `option_id` points at that orphan.

No exception. No log line. The site accumulates orphaned answers on every create, and the ones that are
visible look fine, so nobody investigates. This is worth stating plainly: **the shim is what makes the
bug invisible.** Had the old model names simply been deleted, this would have been a loud fatal and a
five-minute fix.

**The fix — one model, one row:**

```php
// AFTER — an answer IS the option; it belongs to exactly one question.
$model  = KenedoModel::getModel('ConfigboxModelAnswers');
$answer = $model->initData();
$answer->question_id = $questionId;              // required
$answer->title       = 'Red';
$model->store($answer);                          // → #__configbox_answers, one row
```

Two things that travel with this change:

- **Delete the rollback.** Two-step creates usually had `$optionModel->delete($optionId)` to undo step 1
  when step 2 failed validation. There is no separate row to undo any more, and that id is now an
  *answer* id — so the rollback does not merely no-op, it can **delete a live answer**.
- **Drop the "title lives on the option, not the xref" branching.** Any code that fetched the option
  record to set a title, then stored it separately from the assignment, collapses to editing the answer
  record you already have. It reads as perfectly sensible code, which is why it is easy to miss.

**Guard and detection.** `ConfigboxModelAnswers::store()` now refuses an insert whose `question_id`
is empty, logs the reason (`logs/configbox/`), and returns false — the old idiom fails loudly instead of
corrupting data. `question_id` is nullable in the schema and carries no foreign key, so the database will
never catch this on its own; the model guard is the only backstop. Updates are deliberately left
unguarded, so an existing orphan can still be repaired by assigning it a question and re-saving.

To find damage already written on a site that ran the two-step idiom under CB4:

```sql
-- Orphans (expect 0):
SELECT COUNT(*) FROM `#__configbox_answers` WHERE `question_id` = 0 OR `question_id` IS NULL;

-- Inspect before deleting — check whether a live answer points at each orphan via the breadcrumb:
SELECT o.`id`, o.`option_id`, (
  SELECT COUNT(*) FROM `#__configbox_answers` r WHERE r.`option_id` = o.`id`
) AS `referenced_by`
FROM `#__configbox_answers` o
WHERE o.`question_id` = 0 OR o.`question_id` IS NULL;
```

An orphan with `referenced_by > 0` is one half of a two-step create: the referencing answer is the real
one and carries the data, so the orphan can go — but re-point or clear the survivor's `option_id` first
if your own re-linking migration (§3.8) still depends on that breadcrumb.

## 4. Decision procedure (per customization file)

For each customization file that matched a grep above:

1. **Is it under `data/customization/` and actually loaded on this install?** Some "brand" (bc*) code is
   dormant (references tables that don't exist here). Confirm the tables/classes it uses exist before
   spending effort; if the feature is dead on this deployment, note it and move on.
2. **Model-property customization?** → follow [model-property-customizations.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/model-property-customizations).
   Most cases auto-convert; the manual cases are the `option_id` join re-export and the external-table
   re-key (§3.8).
3. **Raw SQL / cache / pricing / order reader?** → apply the §3 mappings. These have **no shim** — they
   must be edited.
3b. **Does the file CREATE options/answers rather than read them?** → §3.10. This one does not announce
   itself in any grep for removed identifiers, because it uses names that all still resolve.
4. **External-store append fields?** → code key auto-remapped, but **you owe a data migration** (§3.8).
5. **After editing**, grep the file again for every removed identifier: `xref_element_option`,
   `configbox_options`, `option_id`, `xref_id`, `ConfigboxModelAdminoptions`,
   `ConfigboxModelAdminoptionassignments`, `ConfigboxModelAdminxrefelementoptions`, `getXref`, `*ByXref`,
   `xref_to_`/`_to_xref`. Each remaining hit must be justified (e.g. an unrelated `xref_country_zone` table)
   or fixed.

## 5. Worked conversions

**A shared-option customization → per-answer.** A custom column added to every option and read on the
answer:
```php
// BEFORE — read a custom option column via the merged answer
$value = $answer->my_option_col;           // came from options table, merged at cache time
// AFTER — same column now lives on the answer row directly; no change to this read,
// but the ALTER that added it must target #__configbox_answers, not #__configbox_options.
```

**A raw options↔xref join.**
```php
// BEFORE
$sql = "SELECT o.sku, xref.element_id
        FROM `#__configbox_xref_element_option` xref
        LEFT JOIN `#__configbox_options` o ON o.id = xref.option_id
        WHERE o.sku = '".$db->getEscaped($sku)."'";
// AFTER — one table
$sql = "SELECT a.sku, a.question_id
        FROM `#__configbox_answers` a
        WHERE a.sku = '".$db->getEscaped($sku)."'";
```

**A title lookup.**
```php
// BEFORE
$title = ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->option_id);
// AFTER
$title = ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->id);
```

**An external-append property (code + the migration you owe).**
```php
// customization property def — the core remaps this key for you at load time…
'storeExternally'  => true,
'foreignTableName' => '#__configbox_external_option_appends',   // rename table too if you own it
'foreignTableKey'  => 'option_id',   // -> core reads it as 'answer_id'
// …but you must ship a data/customization/updates/<ver>.php that:
//   1) adds an `answer_id` column to the append table (guard with tableFieldExists)
//   2) fans out each option-append row to every answer that used that option, keyed by answer_id
```

## 6. Why it was done (context for judgement calls)

The two-table model existed for one feature: **reusing a single answer definition across many questions.**
In practice that reuse caused more confusion than it saved (edit-one-affects-many surprises, a whole
"assignment" editing layer, doubled ids, the merge machinery). CBX 4 trades it for a clean 1:n:
every answer belongs to one question. When a judgement call is ambiguous during conversion, prefer the
interpretation that treats **the answer as a self-contained, single-question entity** — that is the model
you are converging on.
