Deep dive: the option/answer collapse (xref → answer)
- Version
- 4.0 preview
- Updated
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 and the mechanical recipe in
model-property-customizations.md. 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 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'spostflightapplies 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_stringsand the frozencbcheckout_order_stringsfromoption_idtoanswer_id, renamescbcheckout_order_configurations.xref_id→answer_id(dropping itsoption_id), and drops#__configbox_options+#__configbox_xref_element_option. Where sections below say the core "migrates" / "re-keys" / "fans out", that is what3.5.3.phpdoes.3.6.2.phpthen backfills theanswers.option_idbreadcrumb (§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_optionsand 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_stringskeyed 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_stringstypes 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_optionsand#__configbox_xref_element_optionare dropped. Theoption_idcolumn and theoption_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_optionsinstead 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_idas a join key (options↔xref) → the join is gone; read the column off the answer.option_idas the strings key (title/description lookups) → use the answer id.- The
option_idjoin property ("Reused Answer",type=join→ConfigboxModelAdminoptions) → remove it. There is no shared option to point at. A custom join subtype likebcoptionjointhat 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).
// BEFOREConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->option_id);// AFTERConfigboxCacheHelper::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::classused 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 toanswer_to_question/question_to_answer(the current live keys).answer_to_element/element_to_answerstill resolve but only as legacy aliases now — target*_to_question/question_to_*in new code. See element-question-rename.md.
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_configurationsused to carry bothxref_idandoption_id. It now carries a singleanswer_id(xref_idrenamed,option_iddropped). 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.3re-keys existing orders' frozen strings fromoption_idtoanswer_id, mapping throughorder_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 byoption_id, switch toanswer_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_idis NULL on a database that was collapsed before the column existed (3.6.2adds 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'snormaliseLegacyCustomPropertyDefinitions()remaps the code keyoption_id → answer_idautomatically — but it does NOT touch the table, and3.5.3drops 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; theiroption_idvalues now point at ids that no longer exist. You must:- add an
answer_idcolumn to that external table (guard withtableFieldExists), and - migrate the rows via the breadcrumb — one option-append fans out to N answer-appends:
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.INSERT INTO `#__configbox_external_option_appends_new` (`answer_id`, /* …your columns… */)SELECT a.`id`, /* …your columns… */FROM `#__configbox_external_option_appends` AS apJOIN `#__configbox_answers` AS a ON a.`option_id` = ap.`option_id`;
- 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. - Run and verify it via the CLI rather than waiting for a web request:
configbox:migrateapplies it (and names it in the output),configbox:migrate --statusconfirms nothing is left pending, and if your script throws,configbox:migrate -vprints the failing script + exception chain + log tail, andconfigbox:migrate:unblocklifts the freeze once you have fixed it. Remember core scripts run before customization scripts, so by the time yours executes,#__configbox_optionsis already gone — exactly why the breadcrumb join above is the only route.
- add an
- 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 itsanswer_idvalues stay correct. Its foreign key constraint, however, referenced the xref table and is removed by3.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 viainformation_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 in3.5.2),#__configbox_xref_element_optionand#__configbox_options(dropped in3.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 adata/customization/updates/script, pointing at the successor table. Each removal is logged toconfigbox_upgrade_errors.logwith 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 TABLEfail and freezes the migration — deliberately: the site owns that constraint and must remove or re-point it itself, then re-run (configbox:migrate:unblock, thenconfigbox: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 customgetCopiedConditionData).
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:
// 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_idpoints 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:
// 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:
-- 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:
- 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. - Model-property customization? → follow model-property-customizations.md.
Most cases auto-convert; the manual cases are the
option_idjoin re-export and the external-table re-key (§3.8). - 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.
- External-store append fields? → code key auto-remapped, but you owe a data migration (§3.8).
- 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 unrelatedxref_country_zonetable) or fixed.
5. Worked conversions
A shared-option customization → per-answer. A custom column added to every option and read on the answer:
// 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.
// 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.
// 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).
// 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.