# CBX 4 — consolidated old → new identifier cheat-sheet

> One flat lookup table of every renamed/removed identifier in the CBX 4 refactor, across all domains . Built for the "amend the customizations" workflow: load…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/CHEATSHEET. Last updated 2026-08-14.

---
**One flat lookup table of every renamed/removed identifier in the CBX 4 refactor**, across all
domains (element→question rename, option/answer collapse, admin theming, configurator store). Built for the
"amend the customizations" workflow: load this, scan the customization for any **Old** token, apply the
**New**, then heed the **Status** column.

This is the *index*. The exhaustive per-domain references, with worked conversions and the per-file decision
procedure, are:
[element-question-rename.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/element-question-rename) ·
[answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive) ·
[model-property-customizations.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/model-property-customizations) ·
[breaking-changes-log.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/breaking-changes-log).

Every pair below was verified against the CB4 code (not memory). If a row and a playbook ever disagree,
the playbook wins — flag it.

---

## How to read the Status column

| Tag | Meaning | What it means for you |
|---|---|---|
| 🟢 **shim** | Core keeps the old identifier working (alias/deprecated method/legacy key). | Cleanly-written code still runs; update at leisure, a deprecation is logged. |
| 🔴 **manual** | Hard rename/removal, **no shim**. | You must edit the customization or it breaks (fatals or silently wrong). |
| 🟡 **manual+data** | Code side may be shimmed, but stored **data** must be migrated too. | Edit code **and** ship a `data/customization/updates/` migration. |
| 🟠 **data-migrated, code not** | Core rewrote the stored **data** to the new vocabulary; your code that reads or writes it was **not** touched. | **The data moved out from under your code.** Nothing fatals — your reader just gets `null` and your writer produces rows nothing evaluates. Must be fixed in the same release as the upgrade. |
| ⛔ **removed** | The thing is gone with no successor. | Delete the reference / rethink the feature. |

> 🟠 is not a milder 🔴 — it is the **most dangerous** tag on this sheet, because "core migrated it for
> you" reads as reassurance and the failure is invisible. See "How this breaks silently" below.

> ✅ **Both DB migrations ship and run automatically.** element→question is `helpers/updates/3.5.2.php`;
> the option/answer collapse is **`helpers/updates/3.5.3.php`** (+ `3.6.2.php`, which backfills the
> `answers.option_id` breadcrumb on databases collapsed before it existed). They run during the package
> install/upgrade itself, so rows tagged **collapse** that say the core "migrates" data describe **released
> behaviour** — the DB is already converted by the time you look at it. What the core does *not* migrate is
> your own customization tables: see §3.8 of
> [answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive) for the hand-over via `answers.option_id`.

> ⚠️ **The stacked rename — never emit the intermediate name.** The option/answer collapse first produced
> `answer_to_element` / `element_to_answer` (and `answer.element_id`), then the element→question rename
> renamed those to the **live** `answer_to_question` / `question_to_answer` / `answer.question_id`. This
> sheet always lists the **final** target. The intermediate `*_element*` forms still resolve **only as legacy
> aliases** — do not target them in new code.

---

## 0. How this breaks silently — read this first

The loud failures in this migration (a missing table, a class that no longer exists) are fixed in
minutes: PHP tells you exactly where to look. **Everything that actually costs time fails silently** —
wrong-but-plausible behaviour, no exception, no log line, nothing to grep for and nothing a smoke test
catches. Real migrations of heavily-customized sites hit these six, in roughly this order of pain:

| # | Silent failure | Why nothing throws | Cheat-sheet section |
|---|---|---|---|
| 1 | **Two-step answer creation writes duplicate rows.** Old code created an "option", then an "xref" linking it to the question. Both model names are now shims for the *same* model, so it inserts an orphan answer plus a real one, on every create. | The shims are documented as the *safe* option. Deleting them would have fataled loudly. | §3 ⚠️ |
| 2 | **Stored rule/calc JSON was migrated; your code that reads it was not.** Core rewrote every rule to `{"type":"QuestionProperty","questionId":…}`. A custom condition class still reading `$conditionData['elementId']` gets `null`. | `null` is a valid array miss, not an error. Every custom rule silently stops matching. | §7 🟠 |
| 3 | **Code that *writes* rules still emits the old vocabulary.** The dispatcher builds a class name from the stored `type` string, so `"type":"ElementAttribute"` resolves to a class that no longer exists — the rule simply never evaluates. | Writers are easy to forget: the data migration makes all *existing* rows look correct. | §7 🟠 |
| 4 | **Property reads on model records** — `$answer->element_id` returns `null` → `(int)` → `0`. A guard comparing against it never fires again. | It looks like PHP, not SQL, so a "search your raw SQL" sweep misses it entirely. | §2 |
| 5 | **Translation lookups keyed by the old option id.** `answers.option_id` still *exists* as a breadcrumb, so the lookup keeps returning *something* — usually the wrong string. | A wrong label is not an error. Directly user-visible. | §9 |
| 6 | **The rule editor un-migrates rows on re-save.** A custom condition chip still carrying `data-element-id` is serialized back into the stored JSON (camelCased) every time an admin saves the rule. Chip markup must be rewritten too: `data-element-id` → `data-question-id`. | The data migration rewrote the row, readers read the new keys, tests pass — the stale chip markup only fires when a human edits a rule. | §7 🟠 |

**The single most valuable debugging habit:** query the database for what the stored JSON *actually*
contains rather than reasoning about it. Start here, before reading any code:

```sql
SELECT `rules` FROM `#__configbox_answers` WHERE `rules` != '' LIMIT 1;
```

If that shows `questionId` and your code says `elementId`, you have found failure #2 in ten seconds.

---

## 1. DB tables (raw SQL)

| Old | New | Status | Domain |
|---|---|---|---|
| `#__configbox_elements` | `#__configbox_questions` | 🔴 manual | rename |
| `#__configbox_xref_element_option` | `#__configbox_answers` | 🔴 manual · collapse-data | collapse |
| `#__configbox_options` | ⛔ removed — columns folded onto `#__configbox_answers` | ⛔ removed | collapse |

> Hard-coded table prefixes evade grep. Also search the bare stems `configbox_elements`, `configbox_options`,
> `xref_element_option` (e.g. `e5xae_configbox_options`), not only the `#__` form.

## 2. DB columns (raw SQL / property reads)

| Old | New | Status | Domain |
|---|---|---|---|
| `element_id` (question FK, e.g. on answers, order configs, cart position configs) | `question_id` | 🔴 manual | rename |
| `element_id_a` … `element_id_d` (`#__configbox_calculation_codes`) | `question_id_a` … `question_id_d` | 🔴 manual | rename |
| `column_element_id`, `row_element_id`, `multielementid` (`#__configbox_calculation_matrices`) | `column_question_id`, `row_question_id`, `multiquestionid` | 🔴 manual | rename |
| `element_custom_1` … `element_custom_4` | `question_custom_1` … `question_custom_4` | 🔴 manual | rename |
| `element_css_classes` | `question_css_classes` | 🔴 manual | rename |
| `element_type`, `element_code` (`#__cbcheckout_order_configurations`) | `question_type`, `question_code` | 🔴 manual | rename |
| `label_element_custom_1..4`, `label_element_custom_translatable_1/2` (`#__configbox_config`) | `label_question_custom_*` | 🔴 manual | rename |
| `pm_regular_show_elements` / `pm_recurring_show_elements` (config + products) | `pm_*_show_questions` | 🔴 manual | rename |
| `pm_regular_show_elementprices` / `pm_recurring_show_elementprices` | `pm_*_show_questionprices` | 🔴 manual | rename |
| `xref_id` (`#__cbcheckout_order_configurations`) | `answer_id` | 🔴 manual · collapse-data | collapse |
| `option_id` (join key / strings key / `$answer->option_id` in code) | ⛔ removed — use the **answer id** (`answer.id`) | ⛔ removed | collapse |
| `#__configbox_answers`.`option_id` | ⚠️ **exists, but is a legacy breadcrumb only** — the old option id each answer came from. Nothing in CBX reads it; it is there *solely* to re-link customization data keyed by option id (§3.8). Never use it as a live join. NULL on DBs collapsed before `3.6.2`. | ℹ️ informational | collapse |

> `element_id` renames to `question_id` **only** as this entity's FK. Do **not** touch Joomla core
> `#__extensions.element` / `#__updates.element`, nor WordPress WPML `$translation->element_id` in
> `helpers/wordpress.php`.

> **These are property reads too, not only SQL.** The column rename reaches every model record you hold
> in PHP, and that half is routinely missed because a sweep for "raw SQL naming removed columns" does not
> look like it applies:
>
> ```php
> // BEFORE — no error; the property is simply absent, so this is null:
> if ((int)$answer->element_id !== $expectedQuestionId) { … }   // (int)null === 0 → guard never fires
> // AFTER:
> if ((int)$answer->question_id !== $expectedQuestionId) { … }
> ```
>
> Grep for `->element_id` and `['element_id']` separately from the SQL sweep.

### Customer fields (`#__configbox_users` + `#__cbcheckout_order_users`) — playbook: [customer-field-rename.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/customer-field-rename)

Same names on both tables; also stored as **data** in `#__configbox_user_field_definitions.field_name`
(rows rewritten by `3.7.0.php`), used as **form input names / CSS classes / validation fieldName values**,
and (with the augmented keys) as **template placeholders** (stored texts rewritten by `3.7.0.php` 🟠).

| Old | New | Status | Domain |
|---|---|---|---|
| `billingcompanyname`, `billingfirstname`, `billinglastname`, `billingphone`, `billingemail`, `billingcity` | `billing_company_name`, `billing_first_name`, `billing_last_name`, `billing_phone`, `billing_email`, `billing_city` | 🔴 manual | customer |
| `billingaddress1` / `billingaddress2` | `billing_address_line_1` / `_2` | 🔴 manual | customer |
| `billingzipcode` | `billing_postal_code` | 🔴 manual | customer |
| `billingcountry` / `billingstate` | `billing_country_id` / `billing_state_id` | 🔴 manual | customer |
| `billingsalutation_id`, `billingcounty_id`, `billingcity_id` | `billing_salutation_id`, `billing_county_id`, `billing_city_id` | 🔴 manual | customer |
| bare `companyname`, `firstname`, `lastname`, `phone`, `email`, `city`, `address1/2`, `zipcode`, `country`, `state`, `salutation_id`, `county_id`, `city_id` **on a customer/order-address record** | `shipping_company_name`, `shipping_first_name`, … `shipping_country_id`, `shipping_state_id`, `shipping_salutation_id`, `shipping_county_id`, `shipping_city_id` | 🔴 manual | customer |
| `vatin` | `vat_number` | 🔴 manual | customer |
| `samedelivery` | `shipping_same_as_billing` | 🔴 manual | customer |
| `gender`, `billinggender` (columns) | ⛔ removed — `shipping_gender` / `billing_gender` exist as **augment-only** keys derived from the salutation | ⛔ removed | customer |
| augmented `countryname`/`statename`/`statecode`/`statefips`/`county`/`salutation` (+ `billing*` twins) | `shipping_country_name`, `shipping_state_name`, … / `billing_country_name`, … | 🔴 manual | customer |
| placeholders `{country}`, `{billingcountry}`, `{state}`, `{billingstate}` (name-resolving aliases) | `{shipping_country_name}`, `{billing_country_name}`, `{shipping_state_name}`, `{billing_state_name}` | 🟠 data-migrated, code not | customer |

> The bare-name row needs judgement: the same words are legitimate on the geo tables
> (`#__configbox_cities.county_id`), the tax-rate table, platform-user objects and the cascade AJAX
> params (`country_id`/`state_id`/`county_id`) — those stay. Only *customer/order-address* usages move.

## 3. PHP classes

| Old | New | Status | Domain |
|---|---|---|---|
| `ConfigboxModelAdminelements` | `ConfigboxModelQuestions` | 🟢 shim (`getModel()` alias) | rename |
| `ConfigboxControllerAdminelements` | `ConfigboxControllerAdminquestions` | 🔴 manual | rename |
| `ConfigboxViewAdminelements` / `ConfigboxViewAdminelement` | `ConfigboxViewAdminquestions` / `ConfigboxViewAdminquestion` | 🔴 manual | rename |
| `ConfigboxConditionElementAttribute` | `ConfigboxConditionQuestionProperty` | 🔴 manual (stored JSON migrated) | rename |
| `ConfigboxCalcTermElementAttribute` | `ConfigboxCalcTermQuestionProperty` | 🔴 manual (stored JSON migrated) | rename |
| `ConfigboxModelAdminoptions` | `ConfigboxModelAnswers` | 🟢 shim on **read** · ⚠️ **unsafe on write** | collapse |
| `ConfigboxModelAdminoptionassignments` | `ConfigboxModelAnswers` | 🟢 shim on **read** · ⚠️ **unsafe on write** | collapse |
| `ConfigboxModelAdminxrefelementoptions` | `ConfigboxModelAnswers` | 🟢 shim on **read** · ⚠️ **unsafe on write** | collapse |
| controllers/views `adminoptions`/`adminoptionassignments`/`adminxrefelementoptions` (+ singular) | `adminanswers` / `adminanswer` | 🔴 manual | collapse |

> The `getModel()` alias returns the right model, but the old class **no longer exists** — a bare
> `ConfigboxModelAdminoptions::class` string resolves via the alias, yet any other use of that class name
> (`new`, `instanceof`, `extends`) fatals. Update it.

> ⚠️ **If your code CREATES options and xrefs in two steps, it is now writing duplicate answer rows.
> Fix this before upgrading.** All three model names above resolve to the *same* model, so the standard
> pre-CB4 creation idiom still runs without error — and inserts twice:
>
> ```php
> // BEFORE (pre-CB4) — and still "works" after the upgrade, which is the problem:
> $opt = KenedoModel::getModel('ConfigboxModelAdminoptions')->initData();            // 1. the option
> $opt->title = 'Red'; $optionModel->store($opt);
> $x = KenedoModel::getModel('ConfigboxModelAdminxrefelementoptions')->initData();   // 2. link to question
> $x->element_id = $questionId; $x->option_id = $opt->id; $xrefModel->store($x);
> // Result on CB4: TWO answer rows — an orphan with no question_id, plus a real one whose
> // option_id points at the orphan. No exception, no log line. Repeats on every create.
>
> // AFTER — one model, one row:
> $model = KenedoModel::getModel('ConfigboxModelAnswers');
> $answer = $model->initData();
> $answer->question_id = $questionId;   // required: an answer belongs to exactly one question
> $answer->title = 'Red';
> $model->store($answer);
> ```
>
> `ConfigboxModelAnswers::store()` now refuses an insert with an empty `question_id` and logs why,
> so the old idiom fails loudly instead of corrupting data. That guard cannot repair rows written
> before it existed — on a site that ran the two-step idiom under CB4, count the damage with:
>
> ```sql
> SELECT COUNT(*) FROM `#__configbox_answers` WHERE `question_id` = 0 OR `question_id` IS NULL;
> ```
>
> Anything above zero is orphaned rows to delete (check first that no live answer's `option_id`
> points at them). Full narrative: §3.10 of
> [answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive).

> **Rollback code becomes wrong, not merely redundant.** Two-step creates typically rolled back with
> `$optionModel->delete($optionId)` on a validation failure. Post-collapse there is no separate option
> row — that id is an *answer* id, so the rollback can now delete a live answer. Delete the rollback
> along with the two-step create.

## 4. Methods

| Old | New | Status | Domain |
|---|---|---|---|
| `ConfigboxPrices::getElementPrice()` | `getQuestionPrice()` | 🟢 shim (`@deprecated` alias) | rename |
| `ConfigboxPrices::getElementPriceRecurring()` | `getQuestionPriceRecurring()` | 🟢 shim (`@deprecated` alias) | rename |
| `ConfigboxPrices::getElementWeight()` | `getQuestionWeight()` | 🟢 shim (`@deprecated` alias) | rename |
| `getElementAttributes()` (on the rule/calc term class) | `getQuestionProperties()` | 🔴 manual | rename |
| `ConfigboxPrices::getXrefPrice()` | `getAnswerPrice()` | 🔴 manual | collapse |
| `ConfigboxPrices::getXrefPriceRecurring()` | `getAnswerPriceRecurring()` | 🔴 manual | collapse |
| `ConfigboxPrices::getXrefWasPrice()` | `getAnswerWasPrice()` | 🔴 manual | collapse |
| `ConfigboxPrices::getXrefWasPriceRecurring()` | `getAnswerWasPriceRecurring()` | 🔴 manual | collapse |
| `ConfigboxPrices::getXrefWeight()` | `getAnswerWeight()` | 🔴 manual | collapse |
| `configurator.registerQuestion(type, obj)` (JS) | `configurator.registerQuestionType(type, obj)` | 🟢 shim (deprecated alias kept) | rename |
| `postMakeSelection(&$response)` (CB3 function hook) | unchanged — still called by `ConfigboxControllerConfiguratorpage::sendResponse()` on every makeSelection | 🟢 works as-is | hook |
| `system_overrides/` eager loader (classes + function hooks) | unchanged — same directory, same `require_once` scan at boot | 🟢 works as-is | hook |

> ℹ️ A CB3 customization that fed its live UI through `postMakeSelection` (the classic use: the
> configuration-code bar) ports without touching that seam — verified with a real CB3 port
> (Beta Calco's code bar, rebuilt on CBX 4 on the same hook). What DID change around it: the
> response is consumed via the `serverResponseReceived` jQuery event on the client, and
> selections your hook reads should go through `ConfigboxConfiguration` + the question types'
> methods (`getSku()`, `isEmptySelection()`) rather than CB3's bespoke selection tables.

> ⚠️ **The JS registration enforces the full handler set.** `registerQuestionType()` **throws,
> listing the missing methods**, unless the object carries all nine handlers (`init`,
> `onQuestionActivation/Deactivation`, `onAnswerActivation/Deactivation`,
> `onSystemSelectionChange`, `onValidationChange`, `onValidationMessageShown/Cleared`) — and the
> throw happens inside `initQuestions()`'s page walk, so one incomplete CB3-era module takes the
> whole configurator page down. Port with `configurator.defineQuestionType(type, methods)`, which
> fills the handlers you don't need with no-ops.

> ⚠️ **`getPrice()` / `getPriceRecurring()` / `getWeight()` overrides on a question-type class are
> not consulted.** Every real consumer (the question view's price display, the stock
> QuestionProperty rule condition and calc term) calls the `ConfigboxPrices` statics directly, and
> those price a selection only when it is a bare answer id (`$question->answers[$selection]`). A
> CB3 customization that priced per-quantity or per-value outside CBX (price-table lookups,
> bespoke quote engines) must be re-authored as a **formula calculation on the question's
> `calcmodel`**, built from a custom calc term that reads the type's selection — that runs inside
> CBX's own engine on every path (display, cart, order). A composite (JSON) selection with no
> calcmodel prices at **0**, silently.

## 5. Object properties

| Old | New | Status | Domain |
|---|---|---|---|
| `$answer->option_id`, `$configuration->option_id`, `ConfigboxAnswer::$option_id` | ⛔ removed — use `$answer->id` | ⛔ removed | collapse |
| `ConfigboxRulesHelper::getQuestions()` rows → `$row->question_type` (CB3: `->element_type` / `->question_type` on the rows) | ⛔ removed from the rows — resolve via `ConfigboxQuestion::getQuestion($row->id)` in a try/catch | ⛔ removed | slimmed rows |

> ⚠️ The `getQuestions()` rows now carry only `id, title, product_id, page_id, answer_count`.
> Rule-editor palettes and calc-term panels that filter questions by type (every custom
> condition/term does) read the type off the row in CB3 — in CBX 4 that raises an
> undefined-property warning which the admin error handler promotes to an exception, so the
> **whole rule editor 500s**. Load the (factory-cached) question instead:
> `ConfigboxQuestion::getQuestion((int)$row->id)->question_type`, guarded with try/catch for rows
> that vanish mid-edit. Verified against a real CB3 customization (Beta Calco's
> `adminruleeditor_lof` view read `$question->question_type` straight off the rows).

## 6. In-memory cache keys (only if you read the caches)

All 🟢 **shim** — the old key stays populated as an alias, but is deprecated. Target the **New** key.
Remember the stacked rename: the `*_element*` intermediates below are themselves now only aliases.

> **New code must read the `question_*` keys, never the legacy aliases** — otherwise the shims can never
> be retired. (Core had drifted here itself: the configurator page view read `page_to_element`, i.e. its
> own deprecation shim. Fixed, but it is why this warning exists.)

> ⚠️ **The assignments map holds only PUBLISHED questions**, on published pages of published products.
> An unpublished or mid-creation question is *legitimately* absent, so a direct read like
> `$ass['question_to_product'][$id]` raises an undefined-key warning — which on any install running
> warnings-as-exceptions (Magento's developer mode does) is a **fatal 500**. Always guard:
>
> ```php
> $ass = ConfigboxCacheHelper::getAssignments();
> if (!isset($ass['question_to_product'][$questionId])) { … handle the absent case … }
> ```
>
> **Or stop having to guard.** `ConfigboxAssignmentsHelper` exposes every relation below as a method
> that returns `null` (point lookups) or `array()` (collections) instead of raising:
>
> ```php
> $productId = ConfigboxAssignmentsHelper::getProductIdForQuestion($questionId);
> if ($productId === null) { … handle the absent case … }
> ```
>
> Full old-key → new-method table in **[assignments-lookups.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/assignments-lookups)**.

| Old key | New key | Domain |
|---|---|---|
| `element_to_product` / `product_to_element` | `question_to_product` / `product_to_question` | rename |
| `element_to_page` / `page_to_element` | `question_to_page` / `page_to_question` | rename |
| `answer_to_element` / `element_to_answer` *(already post-collapse)* | `answer_to_question` / `question_to_answer` | rename (stacked) |
| `xref_to_element` / `element_to_xref` *(pre-collapse)* | `answer_to_question` / `question_to_answer` | collapse → rename (stacked) |
| `xref_to_product` / `product_to_xref` | `answer_to_product` / `product_to_answer` | collapse |
| `xref_to_page` / `page_to_xref` | `answer_to_page` / `page_to_answer` | collapse |
| `calcModelByElement` (+`Recurring`/`Weight`) | `calcModelByQuestion` (+`Recurring`/`Weight`) | rename |
| `taxClassIdByElement` (+`Recurring`) | `taxClassIdByQuestion` (+`Recurring`) | rename |
| `regardingElement` (pricing sub-key) | `regardingQuestion` | rename |
| `priceByXref`, `weightByXref`, `calcModelByXref`, `priceOverridesByXref`, … (`*ByXref`) | `*ByAnswer` | collapse |
| product cache `xrefs.product_<id>` | `answers.product_<id>` | collapse |

## 7. Rule / calc stored-JSON keys (in `rule` / `calculationOverride` data)

🟠 **data-migrated, code not.** Core rewrote the stored rows (including your own `rule`-typed columns, via
model-driven discovery) — and touched **none** of your PHP. Both directions break silently:

| Old | New | Status | Domain |
|---|---|---|---|
| `"type":"ElementAttribute"` | `"type":"QuestionProperty"` | 🟠 | rename |
| `"elementId": …` | `"questionId": …` | 🟠 | rename |
| `"questionId":"regarding"` (sentinel) | *(unchanged — preserved verbatim)* | — | rename |
| `data-element-id` (rule-editor chip markup) | `data-question-id` | 🟠 | rename |
| `data-type="ElementAttribute"` (chip markup) | `data-type="QuestionProperty"` | 🟠 | rename |
| `data-field="selectedOption.id"` (chip markup) | `data-field="selectedAnswer.id"` | 🟠 | collapse |

The three `data-*` rows are the same three JSON keys wearing their HTML form: the rule editor
serializes chip attributes into the stored JSON verbatim (camelCased), so chip markup **must** be
rewritten right along with your PHP readers and writers — see below.

**Fix your READERS.** Every custom rule-condition and calc-term class reads this array. After the
migration the old key is simply absent:

```php
// BEFORE — now silently yields null on every migrated row:
$questionId = $conditionData['elementId'];
// AFTER:
$questionId = $conditionData['questionId'];
```

**Fix your WRITERS too** — they are the easier half to forget, because the data migration makes all
*existing* rows look fine, so nothing looks wrong until someone creates a *new* rule:

```php
// BEFORE — stores a rule that never evaluates: the dispatcher builds the class name from the
// stored 'type' string, and ConfigboxConditionElementAttribute no longer exists.
$rule = array('type' => 'ElementAttribute', 'elementId' => $id, 'field' => 'selectedOption.id');
// AFTER:
$rule = array('type' => 'QuestionProperty', 'questionId' => $id, 'field' => 'selectedAnswer.id');
```

Search your customization for anything that *builds* rule or calc-term arrays — admin controllers,
importers, support tooling, fixtures — not just the classes that consume them.

**The sneakiest writer is the rule editor itself.** The admin rule editor serializes a condition
chip by collecting **all** of its `data-*` attributes (`getItemMetadata()` in
`assets/javascript/rule-editor.js` does `cbj(item).data()`), camelCasing each name — `data-question-id`
becomes the stored key `questionId`, and `data-element-id` becomes `elementId`. Whatever `data-*`
attributes your condition type's `getConditionHtml()` renders **are** the JSON keys that get stored.
So a custom chip still emitting the old attributes turns every admin **re-save** into an
un-migration: the core migration rewrote the row, your reader reads `questionId`, every test passes —
and one edit in the rule editor silently stores `elementId` again, which the evaluator no longer reads.

```php
// BEFORE — in your condition type's getConditionHtml(): every save through the rule editor
// re-poisons the stored rule with a dead type and key:
$html = '<span class="item" data-type="ElementAttribute" data-element-id="'.$id.'" data-field="selectedOption.id">…';
// AFTER — the data-* attribute names ARE the stored JSON keys (camelCased):
$html = '<span class="item" data-type="QuestionProperty" data-question-id="'.$id.'" data-field="selectedAnswer.id">…';
```

Because a stale chip template keeps re-poisoning rows *after* the one-shot data migration, verifying
the stored data once is not enough: after fixing your markup, **re-save a rule that uses your custom
condition through the admin editor, then re-check the row** (sign-off, DB spot-check 1).

## 8. Calc-formula DSL tokens (user-authored calc code)

🟡 **manual+data** — stock code is rewritten by the migration, but there is **no runtime alias**. Rewrite
any calc code your customization generates/stores outside `#__configbox_calculation_codes`.

| Old token | New token |
|---|---|
| `ElementAttribute(` | `QuestionProperty(` |
| `ElementEntry(` | `QuestionSelection(` |
| `ElementPrice(` | `QuestionPrice(` |
| `ElementPriceRecurring(` | `QuestionPriceRecurring(` |
| `RegardingElement(` | `RegardingQuestion(` |

DSL **field-path prefix crumbs** (no alias — the engine resolves only the new prefixes):

| Old prefix | New prefix |
|---|---|
| `selectedOption.` | `selectedAnswer.` |
| `regardingOption.` | `regardingAnswer.` |

Field-path **column** suffixes follow the column rename, e.g. `QuestionProperty(22.element_custom_1)` →
`QuestionProperty(22.question_custom_1)`. The suffix kinds `.id`, `.assignment_custom_N`, `.option_custom_N`,
`.weight`, `.basePriceStatic`, … are otherwise unchanged.

## 9. `#__configbox_strings` type keys

The string **types** are unchanged; what changed is the **id they are keyed by** for answer strings.

| Types | Old key column value | New key column value | Status | Domain |
|---|---|---|---|---|
| 5 (title), 15 (description), 60/61 (custom) | the `option_id` | the **answer id** (`answer.id`) | 🟡 manual+data | collapse |

This is the most common thing customizations do with an option id, and it is a **silent** break: because
`answers.option_id` still exists as a breadcrumb, the old call keeps returning *something* — usually the
wrong string, sometimes null — instead of failing. The symptom is wrong labels in the storefront.

```php
// BEFORE — still runs, quietly returns the wrong string:
$title = ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->option_id);
// AFTER:
$title = ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->id);
```

Flipping the code is only half the job: the **rows** in `#__configbox_strings` are still keyed by option
id, and one option could be shared by many answers, so each row must be *fanned out* to every answer that
used it. That is a `data/customization/updates/` migration driven by the `answers.option_id` breadcrumb —
the procedure is §3.8 of [answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive). Do the code and
the data in the **same** release; either alone leaves labels wrong.

## 10. `model_property_customization/` filenames

🟢 **shim** — the renamed model's `getLegacyCustomizationAliases()` still loads the old filename onto the
new model, so nothing breaks on upgrade day. **Renaming is still the intended end state** (it is the
whole point of CB4, and core cannot retire the aliases while customizations depend on them) — just do it
*completely*. The two traps below are how a half-done rename fails, not reasons to skip it.

| Old filename | New filename | Domain |
|---|---|---|
| `adminelements.php` | `adminquestions.php` | rename |
| `adminoptions.php` / `adminoptionassignments.php` / `adminxrefelementoptions.php` | `adminanswers.php` | collapse |

> ⚠️ **Trap 1 — the filename and the function name must move together.** The loader derives the function
> name from the file's base name (`adminelements.php` → `customPropertyDefinitionsAdminelements`) and
> **silently skips the file** when that function is not found — `function_exists() == false; continue;`.
> Renaming the file but leaving the function named after the old base does not error, does not log:
> your properties just stop being registered, and the admin form quietly loses fields. Rename **both**,
> or **neither**.

> ⚠️ **Trap 2 — renaming an *option* file switches off the compatibility rewriting.** Definitions loaded
> under a **legacy** base are passed through `normaliseLegacyCustomPropertyDefinitions()`, which repoints
> external storage from the option (`'foreignTableKey' => 'option_id'`) onto the answer (`answer_id`) for
> you. That runs *only* for legacy bases — the model's own file is assumed already correct. So renaming
> `adminoptions.php` → `adminanswers.php` means you inherit that repointing job: update every
> `foreignTableKey` in the file by hand (§12) as part of the rename.

> **Do the rename, in one commit:** file → function → (for the option files) `foreignTableKey`, then load
> the admin form and confirm your custom fields are still there. Deferring is legitimate if you are not
> ready — the shim is there precisely so the upgrade is not gated on this — but "renamed the file, forgot
> the function" is the one state that fails silently, and it is worse than either end.

## 11. Copy-pipeline remap buckets

| Old | New | Status | Domain |
|---|---|---|---|
| `$copyIds['adminelements']` | `$copyIds['adminquestions']` | 🔴 manual | rename |
| `$copyIds['adminoptionassignments']` | `$copyIds['adminanswers']` | 🔴 manual | collapse |

## 12. External-storage property keys

| Old | New | Status | Domain |
|---|---|---|---|
| `'foreignTableKey' => 'option_id'` | `'foreignTableKey' => 'answer_id'` | 🟡 manual+data | collapse |

> The core remaps the **code key** `option_id → answer_id` at load time, but does **not** touch the external
> table: you must add an `answer_id` column and **fan out** each option-append row to every answer that used
> that option. See §3.8 of [answers-collapse-deep-dive.md](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/answers-collapse-deep-dive).

## 13. Admin theming (CSS) — token adoption, not a rename

Not an identifier rename; listed so the sheet is complete. Custom **admin** CSS with hardcoded colours won't
flip in dark mode. Replace hardcoded hex with `var(--cb-*)` tokens (full list in
`assets/css/admin-theme.css`); for dark-only overrides add
`[data-bs-theme="dark"] .cb-content <selector> { … }`. A custom `KenedoPlatform` subclass must implement
`getAdminColorScheme()`. Frontend/PDF CSS is intentionally light-only. (🟡 — light unchanged; dark needs work.)

## 14. Configurator client store (JS) — no symbol rename

Not a rename; the state backing changed. `configurator.getConfiguratorData()` / `setConfiguratorDataItem()` /
`replaceConfiguratorData()` / `getQuestionPropValue()` / `questionHasProperty()` are all 🟢 **shimmed** over
the new `configbox/store`. **One gotcha:** reading `#configurator-data`'s raw `data-json` attribute now yields
only the frozen initial seed — for *current* state read the store (`configurator.getConfiguratorData()` or
`cbrequire(['configbox/store'], …)`). CBX's jQuery is AMD-scoped (`cbj`), not a page global.

---

## Grep pack — prove a customization is clean

Run all three after editing. Any hit must be either fixed or a justified false-positive (an unrelated table,
or an intentional back-compat line of your own).

**element → question:**
```
grep -rnE "configbox_elements|element_id|element_custom_|element_css_classes|element_type|element_code|label_element_custom|pm_(regular|recurring)_show_element|ConfigboxModelAdminelements|ConfigboxControllerAdminelements|ConfigboxViewAdminelement|ConfigboxConditionElementAttribute|ConfigboxCalcTermElementAttribute|column_element_id|row_element_id|multielementid|joinedby_element_id_to_adminelements|ElementAttribute\(|ElementEntry\(|ElementPrice\(|RegardingElement\(|selectedOption\.|regardingOption\.|element_to_|_to_element|ByElement|regardingElement|getElementPrice|getElementWeight|getElementAttributes|\['adminelements'\]" data/customization/
```
Ignore `helpers/wordpress.php`-style `$translation->element_id` (WPML) and Joomla `#__extensions.element`.

**option/answer collapse:**
```
grep -rnE "xref_element_option|configbox_options|option_id|xref_id|ConfigboxModelAdminoptions|ConfigboxModelAdminoptionassignments|ConfigboxModelAdminxrefelementoptions|adminoptionassignment|adminxrefelementoption|getXref[A-Za-z]*Price|getXrefWeight|ByXref|xref_to_|_to_xref|\['adminoptionassignments'\]" data/customization/
```
Ignore unrelated stems like `xref_country_zone` / `xref_listing` if your install has them.

**the silent set** — the things neither sweep above reliably catches, because they are ordinary-looking
PHP rather than SQL or class names:

```
grep -rnE "\['elementId'\]|\"elementId\"|'ElementAttribute'|\"ElementAttribute\"|selectedOption\.|->element_id|\['element_id'\]|data-element-id|getModel\('ConfigboxModelAdminoptions'\)|getModel\('ConfigboxModelAdminxrefelementoptions'\)|getTranslation\([^)]*option_id" data/customization/
```

**question-type customizations** — the §4/§5 traps that surface only when a custom type, rule
condition or calc term runs (each hit needs the fix its callout describes, not a rename):

```
grep -rnE "registerQuestion\(|getQuestions\(\)[^;]*->(question|element)_type|->question_type|->element_type|function getPrice\(|function getWeight\(|function getPriceRecurring\(" data/customization/
```
`->question_type` on a question OBJECT (from `ConfigboxQuestion::getQuestion()`) is fine — the trap
is reading it off `ConfigboxRulesHelper::getQuestions()` ROWS. `getPrice()` overrides are dead code:
re-author as a formula calculation on the question's `calcmodel` (see §4).

Hits mean, in order: a rule/calc **reader** still on the old key; a rule **writer** emitting a dead type; a
DSL field path with no alias; a **property read** that now yields null; **rule-editor chip markup that
re-poisons stored rules on every admin save** (§7 — unless it is your own JS's attribute, see the
internal-contracts table); a two-step create that writes duplicate answer rows; a translation lookup
keyed by the wrong id.

**customer field rename:**
```
grep -rnE "billingcompanyname|billingfirstname|billinglastname|billingaddress1|billingaddress2|billingzipcode|billingcity|billingcountry|billingstate|billingcounty_id|billingcity_id|billingemail|billingphone|billingsalutation|billinggender|billingcountryname|billingstatename|vatin|samedelivery|\{country\}|\{countryname\}|\{state\}|\{statename\}|\{salutation\}" data/customization/
```
Then sweep the bare shipping-side names in customer context (judgement needed, see §2's customer block):
```
grep -rnE "->(companyname|firstname|lastname|address1|address2|zipcode|countryname|statename|statecode|statefips)\b|customer-field-(city|state|country|email|phone)\b|\['(companyname|firstname|lastname|zipcode|samedelivery|vatin)'\]" data/customization/
```

---

## Internal contracts — leave these alone

A global find-and-replace breaks all of these. They contain the word "element" or "option" for reasons
that have nothing to do with this migration:

| Thing | Why it stays |
|---|---|
| `option: 'com_configbox'` | Joomla's own URL parameter. Nothing to do with the option→answer collapse — a naive "option → answer" sweep breaks **every** admin URL. |
| Joomla `#__extensions.element`, `#__updates.element` | Joomla core schema. |
| WPML `$translation->element_id` (`helpers/wordpress.php`) | WordPress/WPML's own vocabulary. |
| Your PHP↔JS payload keys (e.g. `config.elementId` posted back as the `element_id` request param) | A contract between **your** PHP and **your** JS. Renaming one side silently breaks the other. Rename both together or neither. |
| Your `data-element-id` HTML attributes | Same: read by your own JS. **Exception — chip markup for the core rule editor is NOT yours to keep:** the editor serializes every `data-*` attribute of a chip into the stored rule JSON (camelCased), so `data-element-id` in your condition type's `getConditionHtml()` re-poisons rows on every admin save and must become `data-question-id` (§7). The rule of thumb: if **your** JS is the only consumer, keep it; if the **editor** serializes it, rename it. |
| Your own side tables and their columns (e.g. `#__configbox_external_element_appends`.`element_id`) | Keep the tables and columns as they are — renaming them is cosmetic and needs its own data migration; a separate project, if ever. **But know that the migration removes their FK constraints:** any FK a `#__configbox_external_*` table holds on `#__configbox_elements`, the xref, or `#__configbox_options` is dropped by `3.5.2`/`3.5.3` and **not re-created** (data untouched; ids keep matching the successor tables — re-add the constraint from your own update script if wanted, see the deep-dive §3.8 contract note). Tables **not** using the `external_` prefix are not touched: their FKs follow the renames automatically, but block the `3.5.3` drops — the migration freezes until the site removes such a constraint itself. |

---

## Sign-off — verify you are done

Work top to bottom. Every item is a check you can actually run; the point is that most of what breaks
here is invisible, so "the site loads" proves very little.

**Code sweeps** (each must be empty, or every hit justified):

- [ ] No raw SQL naming `#__configbox_elements`, `#__configbox_options`, `#__configbox_xref_element_option`.
- [ ] No `->element_id` / `['element_id']` property reads on core records.
- [ ] No `['elementId']` reads in custom rule-condition or calc-term classes (§7 readers).
- [ ] Nothing *writes* `type: ElementAttribute` / `elementId` / `selectedOption.` (§7 writers).
- [ ] No `data-element-id` / `data-type="ElementAttribute"` in rule-editor chip markup
      (your `getConditionHtml()`) — then **re-save a rule using your custom condition in the admin
      editor and re-run DB spot-check 1**: a stale chip un-migrates the row on every save (§7).
- [ ] No `$copyIds['adminelements']` or `$copyIds['adminoptionassignments']` (§11) — this one silently
      corrupts **copied products**, whose rules end up pointing at the original product's questions.
- [ ] No reads of `*_to_element*` / `element_to_*` cache keys (§6), and every assignments-map read is guarded.
- [ ] No two-step option+xref creation, and no `delete()` rollback left over from one (§3).
- [ ] Translation lookups pass the **answer id**, not `option_id` (§9).
- [ ] `model_property_customization/` filenames and their function names still agree (§10).

**Database spot-checks:**

```sql
-- 1. Stored rules speak the new vocabulary (expect questionId, NOT elementId):
SELECT `rules` FROM `#__configbox_answers` WHERE `rules` != '' LIMIT 1;

-- 2. No orphaned answers from two-step creates (expect 0):
SELECT COUNT(*) FROM `#__configbox_answers` WHERE `question_id` = 0 OR `question_id` IS NULL;

-- 3. Migrations actually ran — this must equal the newest file in helpers/updates/:
SELECT `value` FROM `#__configbox_system_vars` WHERE `key` = 'latest_update_version';

-- 4. No frozen update run (expect no row, or 0):
SELECT `value` FROM `#__configbox_system_vars` WHERE `key` = 'failed_update_detected';
```

Check 3 is the one people skip, and it is why "it worked in staging" turns into a 500 in production —
see "Migrations do not announce themselves" below. On Joomla, checks 3 and 4 are one command —
`configbox:migrate --status` — which also names the failed script and error when frozen. The raw SQL
above is the fallback everywhere else (Magento 2; WordPress until `--status` is ported to its
`wp configbox` wrapper; DB-only access).

**Functional pass** (nothing above catches wrong *behaviour*):

- [ ] A configurator page renders, and its **answer labels are correct** (not blank, not another
      answer's label) — that is the §9 translation break.
- [ ] A custom rule actually gates something: change the answer it depends on and watch a question
      appear/disappear. A migrated-but-unread rule looks identical to a rule with no effect.
- [ ] A custom calc term still moves the price.
- [ ] **Copy a product**, then open the copy and confirm its rules reference the *copy's* question ids.
- [ ] Create an answer through your own admin/import code, then re-run orphan check 2 above.
- [ ] The CBX error log is clean: `logs/configbox/`.

---

## Migrations do not announce themselves

CBX tracks its schema version in `#__configbox_system_vars`, **not** in the host platform's
extension registry. So Joomla's extension version and Magento's `setup:db:status` both cheerfully report
"up to date" while CBX migrations are outstanding. The only symptom is a 500 on a product page.

```
php cli/joomla.php configbox:migrate --status     # what is pending, applies nothing (non-zero exit if work is due)
php cli/joomla.php configbox:migrate              # apply it (names the scripts it ran)
php cli/joomla.php configbox:migrate -v           # apply, with full diagnostics if something throws
php cli/joomla.php configbox:migrate:unblock      # lift the freeze left by a failed migration
```

A failed script sets `failed_update_detected` and **freezes every further migration** — deliberately,
since the schema is half-migrated. Nothing clears that on its own; `--status` reports which script
failed and why, and `configbox:migrate:unblock` lifts it once you have fixed the cause.

Migrations also run as a side effect of CBX booting, which means **they run whenever the platform
first initialises Kenedo** — on Joomla during install/upgrade, on Magento via
`Setup/Recurring.php` → `initKenedo()`, i.e. `bin/magento setup:upgrade`. If you update the packages and
never run that, nothing migrates.

> **Ordering, which matters on fresh installs:** `applyUpdates()` runs **all core scripts first, then all
> customization scripts**. On an existing site that is invisible (your customization version is already
> current). On a *fresh* install it is a landmine: core migrates all the way to current — renaming
> `#__configbox_elements` — and only then do your historical `updates/3.5.0.php` scripts run, against a
> schema where the table they name no longer exists. Either write customization update scripts
> defensively against **both** vocabularies (`ConfigboxUpdateHelper::tableExists()` guards), or collapse
> your historical customization migrations into a single current-vocabulary baseline before shipping CB4.
