Skip to main content
Version: 3.x

CBX 4 — consolidated old → new identifier cheat-sheet

Version
3.x
Updated
View markdown

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 · answers-collapse-deep-dive.md · model-property-customizations.md · breaking-changes-log.md.

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

TagMeaningWhat it means for you
🟢 shimCore keeps the old identifier working (alias/deprecated method/legacy key).Cleanly-written code still runs; update at leisure, a deprecation is logged.
🔴 manualHard rename/removal, no shim.You must edit the customization or it breaks (fatals or silently wrong).
🟡 manual+dataCode side may be shimmed, but stored data must be migrated too.Edit code and ship a data/customization/updates/ migration.
🟠 data-migrated, code notCore 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.
removedThe 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 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 failureWhy nothing throwsCheat-sheet section
1Two-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 ⚠️
2Stored 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 🟠
3Code 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 🟠
4Property 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
5Translation 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
6The 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-iddata-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:

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)

OldNewStatusDomain
#__configbox_elements#__configbox_questions🔴 manualrename
#__configbox_xref_element_option#__configbox_answers🔴 manual · collapse-datacollapse
#__configbox_options⛔ removed — columns folded onto #__configbox_answers⛔ removedcollapse

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)

OldNewStatusDomain
element_id (question FK, e.g. on answers, order configs, cart position configs)question_id🔴 manualrename
element_id_aelement_id_d (#__configbox_calculation_codes)question_id_aquestion_id_d🔴 manualrename
column_element_id, row_element_id, multielementid (#__configbox_calculation_matrices)column_question_id, row_question_id, multiquestionid🔴 manualrename
element_custom_1element_custom_4question_custom_1question_custom_4🔴 manualrename
element_css_classesquestion_css_classes🔴 manualrename
element_type, element_code (#__cbcheckout_order_configurations)question_type, question_code🔴 manualrename
label_element_custom_1..4, label_element_custom_translatable_1/2 (#__configbox_config)label_question_custom_*🔴 manualrename
pm_regular_show_elements / pm_recurring_show_elements (config + products)pm_*_show_questions🔴 manualrename
pm_regular_show_elementprices / pm_recurring_show_elementpricespm_*_show_questionprices🔴 manualrename
xref_id (#__cbcheckout_order_configurations)answer_id🔴 manual · collapse-datacollapse
option_id (join key / strings key / $answer->option_id in code)⛔ removed — use the answer id (answer.id)⛔ removedcollapse
#__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.ℹ️ informationalcollapse

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:

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

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

OldNewStatusDomain
billingcompanyname, billingfirstname, billinglastname, billingphone, billingemail, billingcitybilling_company_name, billing_first_name, billing_last_name, billing_phone, billing_email, billing_city🔴 manualcustomer
billingaddress1 / billingaddress2billing_address_line_1 / _2🔴 manualcustomer
billingzipcodebilling_postal_code🔴 manualcustomer
billingcountry / billingstatebilling_country_id / billing_state_id🔴 manualcustomer
billingsalutation_id, billingcounty_id, billingcity_idbilling_salutation_id, billing_county_id, billing_city_id🔴 manualcustomer
bare companyname, firstname, lastname, phone, email, city, address1/2, zipcode, country, state, salutation_id, county_id, city_id on a customer/order-address recordshipping_company_name, shipping_first_name, … shipping_country_id, shipping_state_id, shipping_salutation_id, shipping_county_id, shipping_city_id🔴 manualcustomer
vatinvat_number🔴 manualcustomer
samedeliveryshipping_same_as_billing🔴 manualcustomer
gender, billinggender (columns)⛔ removed — shipping_gender / billing_gender exist as augment-only keys derived from the salutation⛔ removedcustomer
augmented countryname/statename/statecode/statefips/county/salutation (+ billing* twins)shipping_country_name, shipping_state_name, … / billing_country_name, …🔴 manualcustomer
placeholders {country}, {billingcountry}, {state}, {billingstate} (name-resolving aliases){shipping_country_name}, {billing_country_name}, {shipping_state_name}, {billing_state_name}🟠 data-migrated, code notcustomer

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

OldNewStatusDomain
ConfigboxModelAdminelementsConfigboxModelQuestions🟢 shim (getModel() alias)rename
ConfigboxControllerAdminelementsConfigboxControllerAdminquestions🔴 manualrename
ConfigboxViewAdminelements / ConfigboxViewAdminelementConfigboxViewAdminquestions / ConfigboxViewAdminquestion🔴 manualrename
ConfigboxConditionElementAttributeConfigboxConditionQuestionProperty🔴 manual (stored JSON migrated)rename
ConfigboxCalcTermElementAttributeConfigboxCalcTermQuestionProperty🔴 manual (stored JSON migrated)rename
ConfigboxModelAdminoptionsConfigboxModelAnswers🟢 shim on read · ⚠️ unsafe on writecollapse
ConfigboxModelAdminoptionassignmentsConfigboxModelAnswers🟢 shim on read · ⚠️ unsafe on writecollapse
ConfigboxModelAdminxrefelementoptionsConfigboxModelAnswers🟢 shim on read · ⚠️ unsafe on writecollapse
controllers/views adminoptions/adminoptionassignments/adminxrefelementoptions (+ singular)adminanswers / adminanswer🔴 manualcollapse

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:

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

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.

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

OldNewStatusDomain
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()🔴 manualrename
ConfigboxPrices::getXrefPrice()getAnswerPrice()🔴 manualcollapse
ConfigboxPrices::getXrefPriceRecurring()getAnswerPriceRecurring()🔴 manualcollapse
ConfigboxPrices::getXrefWasPrice()getAnswerWasPrice()🔴 manualcollapse
ConfigboxPrices::getXrefWasPriceRecurring()getAnswerWasPriceRecurring()🔴 manualcollapse
ConfigboxPrices::getXrefWeight()getAnswerWeight()🔴 manualcollapse
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-ishook
system_overrides/ eager loader (classes + function hooks)unchanged — same directory, same require_once scan at boot🟢 works as-ishook

ℹ️ 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

OldNewStatusDomain
$answer->option_id, $configuration->option_id, ConfigboxAnswer::$option_id⛔ removed — use $answer->id⛔ removedcollapse
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⛔ removedslimmed 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:

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

$productId = ConfigboxAssignmentsHelper::getProductIdForQuestion($questionId);
if ($productId === null) { … handle the absent case}

Full old-key → new-method table in assignments-lookups.md.

Old keyNew keyDomain
element_to_product / product_to_elementquestion_to_product / product_to_questionrename
element_to_page / page_to_elementquestion_to_page / page_to_questionrename
answer_to_element / element_to_answer (already post-collapse)answer_to_question / question_to_answerrename (stacked)
xref_to_element / element_to_xref (pre-collapse)answer_to_question / question_to_answercollapse → rename (stacked)
xref_to_product / product_to_xrefanswer_to_product / product_to_answercollapse
xref_to_page / page_to_xrefanswer_to_page / page_to_answercollapse
calcModelByElement (+Recurring/Weight)calcModelByQuestion (+Recurring/Weight)rename
taxClassIdByElement (+Recurring)taxClassIdByQuestion (+Recurring)rename
regardingElement (pricing sub-key)regardingQuestionrename
priceByXref, weightByXref, calcModelByXref, priceOverridesByXref, … (*ByXref)*ByAnswercollapse
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:

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

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

// 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.

// 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 tokenNew 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 prefixNew 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.

TypesOld key column valueNew key column valueStatusDomain
5 (title), 15 (description), 60/61 (custom)the option_idthe answer id (answer.id)🟡 manual+datacollapse

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.

// 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. 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 filenameNew filenameDomain
adminelements.phpadminquestions.phprename
adminoptions.php / adminoptionassignments.php / adminxrefelementoptions.phpadminanswers.phpcollapse

⚠️ Trap 1 — the filename and the function name must move together. The loader derives the function name from the file's base name (adminelements.phpcustomPropertyDefinitionsAdminelements) 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.phpadminanswers.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

OldNewStatusDomain
$copyIds['adminelements']$copyIds['adminquestions']🔴 manualrename
$copyIds['adminoptionassignments']$copyIds['adminanswers']🔴 manualcollapse

12. External-storage property keys

OldNewStatusDomain
'foreignTableKey' => 'option_id''foreignTableKey' => 'answer_id'🟡 manual+datacollapse

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.

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:

ThingWhy 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.elementJoomla 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 attributesSame: 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:

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