CBX 4 — breaking changes log (for customizations)
- Version
- 3.x
- Updated
The running, dated index of every core change that can break customization code in data/customization/.
Append a new entry at the top whenever core work introduces another breaking change. Each entry links
to the playbook that carries the detailed fix.
Legend: 🟢 auto-converted (shim present, code still works, deprecation logged) · 🟡 partial (safe cases shimmed, some need manual edits) · 🔴 manual (must edit your customization).
2026-08-17 — admin markup drops its generated ids: view wrappers, property wrappers, list rows, filter controls 🔴
The Kenedo admin markup carried generated id attributes that were never safe to duplicate —
and the moment a form holds two embedded lists of the same child model (the custom-question-type
answers-list pattern), duplicate they did. All four families are gone; the classes, which were
always there alongside, are the selector surface now:
id="view-<viewname>"on view wrappers — gone everywhere (the kenedo default form, every hand-rolled admin template, the invoice/quotation/orderslip PDFs, PSP result pages). Select with the classview-<viewname>(on thegetViewAttributes()wrapper it always sat next to); scripts that need the view NAME readdata-view-id, which the hand-rolled templates now emit like the generic wrapper always has.id="property-name-<prop>"on property and group wrappers — gone. The identical CLASSproperty-name-<prop>has always been on the same element. URL fragments of the form#property-name-<prop>(the AI settings deep links, bookmarks) keep scrolling: kenedo.js now honours that hash against the class.id="item-id-<recordId>"on list rows — gone without replacement.data-item-idon the same<tr>is the machine-readable handle and always was.id="<table>_<column>"on list filter inputs/selects (search boxes, dropdown filters, translatable search, join filters) — gone without replacement. Submission runs on thename; the handlers are class-delegated.
KenedoProperty::getCssId() still exists and still answers property-name-<prop> (customization
code may call it), but nothing in core writes it into markup any more.
Two mechanisms follow from the same cleanup:
Every listing has a state instance key. The remembered list state (user filters, pagination,
sorting) is stored under KenedoView::getListInstanceKey() — default: the lowercased view name,
so standalone list pages keep the exact state slots they always had. Embedded lists override it:
childentries sets <childview>-<propertyName>, so two intra-lists of the same child model on one
form no longer share sort/page/filter state. The key travels with list refreshes as the
list_instance_key request parameter (part of listingData). A view embedding a listing by hand
sets $view->listInstanceKey before prepareTemplateVars().
The subview-ready registry is gone; views announce themselves with an event. On every
processing pass (initial page load and every XHR injection), each view wrapper dispatches the
bubbling native event cbx:view-ready with detail: {view, wrapper} — detail.wrapper is
the instance, so listeners work even with the same view twice on a page. The convention for
future lifecycle events: cbx:<subject>-<state>, native CustomEvent, bubbling, facts in
detail. (Native deliberately: jQuery-triggered events are invisible to addEventListener.)
| Your customization | What happens | Do |
|---|---|---|
CSS/JS selecting #view-<name>, #property-name-<prop>, #item-id-<n> or a filter id | Selector matches nothing — styles vanish, scripts no-op silently | Switch to the class (.view-<name>, .property-name-<prop>, .cb-list-row[data-item-id="<n>"]); filter controls: select by name or .cb-list-filter 🔴 |
Templates echoing getCssId() into an id= attribute | Still renders, but reintroduces the duplicate-id hazard | Drop the id, keep renderCssClasses() 🔴 |
Links to …#property-name-<prop> | Keep working (kenedo.js scrolls to the class) | Nothing 🟢 |
kenedo.registerSubviewReadyFunction(...) | Keeps working through a deprecation shim (translated into a cbx:view-ready listener; logged to the console) | Move to document.addEventListener('cbx:view-ready', …) at leisure 🟢 |
kenedo.runSubviewReadyFunctions(...) / reading kenedo.subviewReadyFunctions | Gone — TypeError | Dispatch or listen for cbx:view-ready instead 🔴 |
| Hand-built list refresh URLs | Still work, but write state under the view-name key | Include the list's list_instance_key parameter (it is in .cb-list-data) 🟡 |
2026-08-13 — two breaks a real CB3 port surfaced, written down: slimmed rule-helper rows, enforced JS handler set 🔴
Porting the Beta Calco (CB3) custom question types to CBX 4 hit two breaks that had never made this log — both are core changes from earlier CB4 work, recorded now with their fixes so the next port does not rediscover them the hard way.
ConfigboxRulesHelper::getQuestions() rows no longer carry the question type. The rows are
slimmed to id, title, product_id, page_id, answer_count. Every CB3-era rule-condition or
calc-term panel filters questions by type by reading it off the row
($question->question_type == 'lof' — verified in a real CB3 customization); in CBX 4 that raises
an undefined-property warning which the admin error handler promotes to an exception, so the
whole rule editor answers 500, not just your tab. Resolve the type through the factory instead:
ConfigboxQuestion::getQuestion((int)$row->id)->question_type, in a try/catch for rows deleted
mid-edit.
configurator.registerQuestionType() enforces the full nine-handler set. The CB3-era
configurator.registerQuestion() name still works (deprecated alias), but registration now throws
— listing the missing methods — unless the object carries all nine handlers, and the throw
surfaces inside initQuestions()'s page walk: one incomplete module takes the entire
configurator page down (it never reaches questions-init-done). Port with
configurator.defineQuestionType(type, methods), which fills the handlers you don't need with
no-ops and gives you standard validation-message handling.
| Your customization | What happens | Do |
|---|---|---|
Rule condition / calc term panel reading ->question_type (or ->element_type) off getQuestions() rows | Rule editor 500s the moment your tab renders | Resolve via ConfigboxQuestion::getQuestion() (try/catch) 🔴 |
| JS type module registering fewer than nine handlers | The whole configurator page dies in initQuestions() | Register through defineQuestionType() 🔴 |
configurator.registerQuestion() by name | Works (deprecated alias) | Rename to registerQuestionType() at leisure 🟢 |
Both failure modes are loud in the browser but silent in the code: nothing greps as obviously
wrong, the code was correct CB3. Find the hits with the CHEATSHEET's question-type
customizations grep (its Grep pack), which also flags the third porting trap in this family —
getPrice()/getWeight() overrides on type classes, which are not consulted (price through a
calcmodel formula instead; see CHEATSHEET §4 and
customization/com_configbox_custom_question_types.md §3).
No data half. Code-only; nothing to migrate.
Key reference: CHEATSHEET §4 (methods callouts) and §5 (slimmed rows) — both added with this entry.
2026-08-11 — getSku() is now a ConfigboxQuestion method, and the order record calls it 🟡
The order record used to read the SKU it freezes onto a line straight off the answer
($answer->sku). It now asks the question — ConfigboxModelOrderRecord::createOrderRecord() does
$configuration->option_sku = (string) $question->getSku($selection->selection); — so a custom
question type can put a SKU on an order line for a selection that is a typed value rather than an
answer id. The base implementation returns the picked answer's sku, so for every stock question
type the frozen value is byte-identical to before, including the '' a non-answer selection wrote.
Three ways a customization can be affected — the first two are about the name being taken, the third about an override that now misses a hook:
| Your customization | What happens | Do |
|---|---|---|
A question sub-class that already declares getSku() with an incompatible signature (no parameter, extra required parameters) | Fatal on load — PHP rejects the incompatible override | Change it to getSku($selection), or rename yours |
A question sub-class that already declares getSku($selection) (or getSku($x)) as its own helper | No error — but core now calls it at checkout, so whatever it returns starts landing in option_sku | Check what it returns for a stored selection. If it was never meant for order lines, rename it |
A system_overrides/ copy of ConfigboxModelOrderRecord | Keeps the old $answer->sku read, so question sub-classes are ignored — silently | Port the one line, or drop the override (§ "Check for a purpose-built hook first" in customization/com_configbox_overriding_controllers_and_models.md §4) |
Kind: needs a manual edit in the first two cases, and there is no shim — the name is either
compatible or it is not. Case 1 fatals on load (Declaration of X::getSku() must be compatible with ConfigboxQuestion::getSku($selection)), which is loud. Cases 2 and 3 are silent: no error,
just a SKU that appears (or fails to appear) on order lines. Find both in one command:
grep -rn "function getSku" data/customization/
grep -rln "ConfigboxModelOrderRecord\|createOrderRecord" data/customization/system_overrides/
No data half. This is a code change only: ConfigboxAnswer::$sku is unchanged, the
#__cbcheckout_order_configurations.option_sku column is unchanged, the API's answer projection
(ConfigboxConfiguratorApiHelper::projectAnswer()) still emits the answer's own sku, and no order
rows are rewritten — the SKU is frozen at checkout, so existing orders keep what they were placed
with.
Playbook: customization/com_configbox_custom_question_types.md §3 — "Giving the order line a
SKU", with a worked override and the four rules the caller relies on.
2026-08-11 — platforms hides a property instead of removing it 🟡
Every property now exists on every platform. KenedoModel::getProperties() no longer filters on
the platforms key — there is one shape on Joomla, WordPress, Magento 2 and standalone, stored,
readable and writable everywhere, because there is one schema and one set of migrations.
platforms still says where a property applies, but it now governs presentation only:
KenedoProperty::isVisible() is false where it does not apply (the field gets the same
invisible-field class an 'invisible' definition produces) and isRequired() is false with it —
that second half is load-bearing, or a hidden field could refuse a save nobody can satisfy.
Why: while the runtime shape differed per host, three surfaces disagreed about which fields exist —
the entity API's writable set (filtered), the generated schemas (the union) and
cbx_describe_entity (the union). On Magento, describe advertised baseprice and the create then
refused it.
| Area | Old | New | Status |
|---|---|---|---|
getProperties() on a host the property excludes | property absent | property present, hidden, never required | 🔴 code using absence as a platform test — isset($props['baseprice']), array_key_exists(...) — is now always true. Test the platform with KenedoPlatform::getName(), or ask $property->appliesToThisPlatform() |
A custom definition declaring 'platforms' | dropped the property on other hosts | hides it there | 🟡 the intent (not in the form, never required) still holds; the property is inert rather than gone |
| Reading/writing such a field off-platform | not possible — no property | accepted, stored, round-trips, nothing reads it | 🟢 |
getPropertiesForAllPlatforms() | the unfiltered union, different from getProperties() | same set as getProperties() | 🟢 kept because it says what the caller means |
$property->isRequired() | the declaration | the declaration for the running platform | 🟡 code generating docs or schemas from it now varies per host — read $definition['required'] for the declaration |
| Generated schemas | no platform information | x-configbox-platforms on read AND write, plus a sentence of prose | 🟢 additive |
New: platformDefaults, when a host needs a different starting value
The per-platform if-blocks these declarations replaced did two things — ['invisible'] = true and
['default'] = 0 — and platforms expresses only the first. The companion key carries the second:
'platforms' => array('joomla', 'wordpress', 'standalone'),
'platformDefaults' => array('magento2' => 0),
It changes the value a new record is born with on that host and nothing else — the column, its
DB-level default and every existing row are identical everywhere. Resolved inside
KenedoProperty::getPropertyDefinition('default'), so every caller that builds a blank record picks
it up: a customization reading that accessor gets a platform-specific answer now. Code that wants
the platform-neutral declaration reads $definition['default'] from the array, which is what type
generation does. Reported as x-configbox-platform-defaults.
Playbook: model-property-customizations.md §4.6 — the fix for
the absence test, with the two replacements. Key reference:
../technical/com_configbox_property_definition_settings.md §11–12.
2026-08-11 — deleting a record removes its files at the outermost commit 🟡
Deletion grew a cascade (ConfigboxProductDeleteHelper, and the cbx_delete_product_deep tool)
that walks a product's graph deepest-first inside one transaction. Two pieces of it reach
customization code.
A property that owns files should say so. KenedoProperty::getDeletionFilePaths($id) is new;
the base returns array(), and the file/image properties return the paths the record owns.
KenedoModel::getDeletionFilePaths($ids) collects them, and KenedoDatabase::deferUntilCommit()
runs the removal after the outermost commit — so a rollback anywhere in the cascade leaves every
file where it was, which is the one thing a rollback cannot undo by itself.
| Area | Old | New | Status |
|---|---|---|---|
KenedoProperty::delete() signature | ($id, $data) | unchanged | 🟢 deliberately: a third $dryRun parameter would have made every existing 2-parameter override an incompatible signature, a fatal on PHP 8 |
A custom property type that owns files and unlinks them inside delete() | ran immediately | still runs immediately | 🟡 works, but a rolled-back delete has already destroyed the file. Move the paths into getDeletionFilePaths() and let core defer them |
| Custom code inside a delete needing to know what else is going | no way to ask | KenedoModel::isBeingDeleted($modelName, $id) — true for anything in the current cascade, registered under both the current and the legacy admin-prefixed model name | 🟢 additive |
| Deleting a product that has pages | refused | still refused — the cascade is a separate, explicitly confirmed operation | 🟢 |
beginDeletionScope() / endDeletionScope() bracket the walk; isBeingDeleted() is what a
shared-record guard should consult, so a record another product still uses is never taken along.
Reference: helpers/productdelete.php, and tests/check-after-commit-queue.php in the Joomla site
repo for the nesting behaviour.
2026-08-07 — the configurator stopped decorating questions and answers 🔴
ConfigboxViewQuestion used to write everything it computed for rendering onto the
ConfigboxQuestion and ConfigboxAnswer objects themselves — the css classes, the applies flag,
the resolved image URLs — and, worse, straight over record columns: price stopped being the
answer's price column and became the customer's current price, description became content-modified
HTML, minval/maxval became the calculated bounds rather than the static ones, and
availibility_date — a DATE column — came to hold the sentence "Available on 3 March 2026".
That state now lives on the view (ConfigboxViewQuestion) for the question, and on one
ConfigboxAnswerPresentation per answer — $this->answers, keyed by answer id, each holding a
reference to the answer it describes. The records are never written to. Two things follow:
- A question or answer read after a render now holds its own columns. Code that relied on
$answer->pricebeing the live price after the view ran gets the raw column instead. - Template overrides for question views must be updated. Record reads are unchanged
(
$answer->title,$answer->id,$question->question_type, …) — only the view-computed values moved.
Fixing a question-view template override
The loop changes, with the record aliased back so every record read in the body stays as it was:
// before
<?php foreach ($this->question->answers as $answer) { ?>
// after
<?php foreach ($this->answers as $answerPresentation) {
$answer = $answerPresentation->answer; ?>
Then move the view-computed reads across. $this->question->X → $this->Y — they are now
properties of the view itself, alongside $this->selection, $this->price and $this->outputValue:
| Old | New |
|---|---|
$this->question->applies() | $this->applies |
$this->question->disableControl | $this->disableControl |
$this->question->description | $this->description (content-modified) |
$this->question->minval / maxval | $this->minValue / $this->maxValue |
$this->question->elementImageSrc | $this->imageSrc |
$this->question->elementImageCssClasses | $this->imageCssClasses |
$this->question->elementImagePreloadAttributes | $this->imagePreloadAttributes |
$this->question->getCssClasses() | $this->getCssClasses() (or $this->questionCssClasses, unchanged) |
$answer->X → $answerPresentation->Y:
| Old | New |
|---|---|
$answer->cssId | $answerPresentation->cssId |
$answer->cssClasses | $answerPresentation->getCssClasses() |
$answer->applies | $answerPresentation->applies |
$answer->disableControl | $answerPresentation->disableControl |
$answer->isSelected | $answerPresentation->isSelected |
$answer->description | $answerPresentation->description (content-modified) |
$answer->price / price_recurring | $answerPresentation->price / priceRecurring |
$answer->was_price / was_price_recurring | $answerPresentation->wasPrice / wasPriceRecurring |
$answer->showAvailibilityInfo | $answerPresentation->showAvailabilityInfo (typo fixed) |
$answer->availibility_date (the sentence) | $answerPresentation->availabilityText |
$answer->pickerImageSrc, pickerPreload*, optionImage* | same names on $answerPresentation |
addCssClass() / removeCssClass() / getCssClasses() are gone from ConfigboxQuestion and
ConfigboxAnswer. The question's live on the view ($this->addCssClass(...)), the answer's on its
ConfigboxAnswerPresentation. Custom question-type views that called $question->addCssClass(...)
in prepareTemplateVars() call $this->addCssClass(...).
What did NOT change
#configurator-data is byte-compatible. ConfigboxViewQuestion::getConfiguratorData() and
ConfigboxAnswerPresentation::jsonSerialize() emit the record's keys with the computed values over
the top — under the historical names,
typos included (showAvailibilityInfo, availibility_date, elementImageSrc). Storefront and
customization JavaScript needs no changes. Three deliberate exceptions, all additive or fixes:
elementImageSrcnow carries the image URL instead of always beingnull— see the fix below.elementImageCssClasses/elementImagePreloadAttributesare now always present (''when the question has no image) instead of appearing only for questions that have one.- A question's
cssClassesmap no longer contains an empty-string key when the question has no custom classes, and the renderedclassattribute loses the double space that produced.
Two fixes that ride along
- Question illustrations were rendering with an empty
src.views/question/tmpl/question_decoration.phpreadselementImageSrc, which was declared and read but assigned by nothing, anywhere.$this->imageSrcnow carriesel_image_href. If a site relied on question images being invisible, they will now appear. - Cross-render leakage.
ConfigboxQuestion::getQuestion()returns aclone, and a clone is shallow — every clone shared one set ofConfigboxAnswerobjects. A second render in the same process therefore saw the first render's decoration: content modifiers applied twice, and aselectedclass left on an answer that had since been deselected. With nothing decorating the records, this cannot happen.
2026-07-31 — frontend endpoints answer the envelope: login/logout, customer-form save, cart links 🟡
The response-contract migration reached the storefront's remaining {success: …} endpoints. The
outcome now travels in the HTTP status; a 2xx body is {data, meta}, a 4xx/5xx body is RFC 9457
problem details ({code, detail, errors[], validationIssues[]}). There is no success key on
any of these anymore — customization JS that reads response.success sees undefined, which is
falsy, so an unconverted success handler runs its FAILURE branch.
| Endpoint | Old answer | New answer |
|---|---|---|
user::loginUser | {success, errorMessage}, or a redirect for form posts | 200 + data:{loggedIn, userId} / 401 problem LOGIN_FAILED. Never redirects — the client navigates (the form's return_success/return_failure inputs are read by user.js) |
user::logoutUser | {success: true} | 200 + data:{loggedOut} |
user::store (customer form) | {success, errors[], validationIssues[]} with fieldName keys | 200 + data:{id} + meta.feedback / 422 problem whose validationIssues use field (not fieldName) / 500 STORE_FAILED |
cart::addProductToCart | 303 redirect to the cart or configurator page | 201 + data:{cartPositionId, complete, redirectUrl, cart?|missingSelections?}. The client navigates to data.redirectUrl |
cart::removeCartPosition / editCartPosition / copyCartPosition | redirect flow | unchanged but deprecated (logged as a legacy call). Stock links keep working: configbox/cart::initLinkTasks intercepts any <a> whose href names one of these tasks — customization template overrides included — and calls the envelope twins removePosition/editPosition/copyPosition, navigating on the response |
testautomation::* (E2E only) | {success, error} | envelope / problem+json. Only the Playwright suite consumes this |
What you need to do in customization JS (the mechanical rewrite, one call site at a time):
- Replace
server.makeRequest('user', 'store', data).done(function(response) { if (response.success === false) … })with the choke point:const response = await server.request('user', 'store', data);if (!response.ok) {// response.errors: display-ready strings; response.validationIssues: [{field, code, message}]return;}// response.data holds the payload, response.meta.feedback the success sentenceserver.request()resolves a normalisedCbResponsefor BOTH shapes (it still understands legacy endpoints), so converting a caller is safe even before every endpoint migrated. Full field table and worked examples:docs/technical/com_configbox_frontend_requests.md. - Field-level display: issues from these endpoints carry
field; the model's own arrays carryfieldName.customerform.js'sdisplayValidationIssues()accepts both — code copying its old loop must readissues[i].field || issues[i].fieldName. - Login/logout callers: branch on
response.ok(the status), never on asuccesskey or anerrorMessagestring. A failed login is a 401, not a 200-with-flag. - Templates: keep the stock link markup (or any markup whose href names the task) and the
interception drives it — nothing to change. Only code that scripts
addProductToCartover HTTP must read the201envelope and followdata.redirectUrlitself. - Anything still calling the deprecated redirect tasks directly shows up in the CBX log as a legacy call with the exact replacement named.
2026-07-27 — customer fields renamed: billing_/shipping_ snake_case, gender columns dropped 🔴
The customer record (#__configbox_users) and the frozen order snapshot (#__cbcheckout_order_users)
carried three naming families: glued billing* columns, UN-prefixed columns that actually meant the
shipping address, and oddballs (vatin, samedelivery, id-references without _id). All renamed to
one convention — billing_first_name, shipping_country_id, billing_postal_code,
shipping_address_line_1, vat_number, shipping_same_as_billing — aligned with the commerce2
vocabulary. The dead gender/billinggender columns are dropped (the values become augment-time
derivations from the salutation). The rename reaches the Customer Fields configuration rows
(user_field_definitions.field_name), the customerform's input names and CSS-class/DOM contracts, the
validation-issue fieldName values, and the operator-authored template placeholders —
helpers/updates/3.7.0.php renames the columns, rewrites the field-definition rows and converts stored
notification texts ({billingfirstname} → {billing_first_name}, {country} → {shipping_country_name}, …) 🟠.
No shims: customization code referencing old names needs manual edits.
Playbook: customer-field-rename.md.
2026-07-27 — file/image properties persist the filename by default; four option tags removed 🟢
Uploading a file and recording where it went were separate decisions. image and file only wrote
the uploaded name to their column behind a SAVE_FILENAME option tag; a definition that omitted it
uploaded the file to the data folder, left the column untouched, and reported nothing — the form came
back empty and the record pointed at the old file or at nothing. All ten shipped definitions carried
the tag, which is the clearest evidence it was never a real choice.
Persisting is now the default, with NO_SAVE_FILENAME to opt out.
Separately, three option tags were carried by 32 shipped definitions while being read by nothing — in the framework, the views, the controllers or the encoded engine (checked against the plaintext sources, since the bundles are opaque to grep).
| Tag | Old | New | Status |
|---|---|---|---|
SAVE_FILENAME | persisted the filename | now the default; tag removed from definitions | 🟢 |
NO_SAVE_FILENAME | — | opts out of persisting | new |
PRESERVE_EXT | inert | removed | 🟢 |
NOFILTERSAPPLY | inert | removed | 🟢 |
SKIPDEFAULTFIELD | inert | removed | 🟢 |
What you need to do: nothing, in almost every case. An unknown option tag is ignored, so a
customization still listing any of these keeps working — SAVE_FILENAME is simply redundant now, and
the other three always were. Delete them when you next touch the definition.
The one case that changes behaviour: a custom image/file definition that deliberately
omitted SAVE_FILENAME will now start writing the filename to its column. If your customization
manages that column itself, add NO_SAVE_FILENAME. Nothing in stock did this.
Detail: docs/technical/property-types/image.md, docs/technical/property-types/file.md.
2026-07-27 — translation keys use underscores: title_en_GB, not title-en-GB 🔴
A translatable property named its per-language keys <field>-<languageTag>. A hyphen is not a legal
identifier character in most languages a client is written in, so record.title-en-GB reads as a
subtraction and every consumer had to use bracket access or rename the key on its way out. The
separator and the tag are now underscored at the point the key is built.
| Area | Old | New | Status |
|---|---|---|---|
| Record key for a translatable field | $record->{'title-en-GB'} | $record->title_en_GB | 🔴 rename in custom code |
| Form field name posted by the admin | title-en-GB | title_en_GB | 🔴 custom templates must follow |
| Reading a value in a custom model/property | hand-built $name.'-'.$tag | KenedoLanguageHelper::getTranslationKey($name, $tag) | 🔴 use the helper, do not concatenate |
| Translations from the record API | {"title": {"en-GB": …}} | "title": "…" plus "title_en_GB": "…" | 🔴 the nested map is gone entirely — read the flat per-language keys |
| Sending a translation to the API | {"title": {"en-GB": …}} | {"title_en_GB": "…"} | 🔴 the nested form is refused with 422, naming the key to use |
| MCP tool input keys | title-en-GB | title_en_GB | 🔴 update authoring scripts |
Use the helper, not string concatenation. KenedoLanguageHelper::getTranslationKey() is now the
single place the key is built, so the read side, the write side, the API projection, the MCP schemas
and type generation all derive it identically. The key had been assembled in six places, and
three of them were only found by chasing a failing test — including
checkForDuplicateUrlSegment(), which is silent when a key does not resolve. Concatenating your
own is how you join that list.
Only translatable was affected. Every other property already used an underscore — image's
_href, file's _path, join's _display_value. The convention existed; one type was not
following it.
@see exceptions-and-validation.md for the other change of the same day
2026-07-27 — property definitions gain dataType / nullable, and update 3.6.5 normalises 187 columns 🟡
Property definitions now describe their storage, and update 3.6.5 moves the columns to match.
Both matter to customization code: a custom property type that says nothing about storage gets a
guess, and a customization that reads or writes an affected column may see a different type.
| Area | Old | New | Status |
|---|---|---|---|
| Custom property type | inherited storage silently | must declare getStorageKind() / getDefaultDataType(), or generation warns | 🟡 works, but the generated column is a guess until you declare |
| Custom property definition | no storage keys | may set dataType, nullable | 🟢 optional; defaults come from the type |
| Boolean columns | mixed int / tinyint(1) / varchar(1) | varchar(1) NOT NULL with the declared default | 🟡 custom SQL comparing to 0/1 as integers should compare to '0'/'1' |
| Plain text columns | varchar(16 … 1024), inconsistent | varchar(255) | 🟡 custom SQL is unaffected; custom schema assumptions may not be |
| Dropdown columns | assorted widths | varchar(50) | 🟡 as above |
| Price-override blobs | varchar(1024) | TEXT | 🟢 wider, nothing truncates |
#__configbox_questions.maxval / minval | varchar(255) | decimal(20,8), non-numeric values nulled | 🔴 custom code storing a non-number there will now fail |
22 columns incl. xref_list_product.list_id / product_id | NULL allowed | NOT NULL | 🔴 custom inserts must supply them |
| Foreign keys pointing at a changed column | — | realigned automatically, constraint restored | 🟢 including keys on your tables |
varchar(1000) was tried and is impossible, in case you are tempted: utf8mb4 charges 4 bytes per
character, so a varchar(1000) column costs 4000 bytes of MySQL's 65535-byte row limit —
#__configbox_questions alone would need about 132000. The migration failed with "Row size too
large" before settling on 255.
The migration refuses rather than invents. A column that would have to narrow below the data it holds is skipped with a log line; a NOT NULL change on a column holding NULLs is skipped rather than backfilled with a value nobody chose. Clean the rows and re-run.
Your foreign keys are handled. MySQL will not modify a column another key points at, and
FOREIGN_KEY_CHECKS=0 does not lift that — the type compatibility of a key is structural. The
migration looks the referencing side up in information_schema, so a key from your own
customization table is dropped, realigned and restored with its original delete/update rules rather
than being broken or silently skipped.
@see ../technical/com_configbox_property_definition_settings.md
2026-07-27 — errors are exceptions; setError() / getErrors() are GONE 🔴
A refusal used to be false plus a string in setError(). That could not say whether the problem
was the caller's ("you left the title empty") or the system's ("the database is gone"), so every
layer guessed — and during copy(), which recurses into child entities, each level caught,
flattened the reason to a string and returned false. A fixable problem reached the operator as
"A system error occurred during copying. Please notify your service provider."
Refusals are now exceptions, and they say which kind they are:
| Class | Means | Answered with |
|---|---|---|
KenedoValidationException | the caller can fix it | 422, per-field issues |
KenedoSystemException | the caller cannot | 500, detail to the log only |
KenedoException | base of both | — |
| Area | Old | New | Status |
|---|---|---|---|
Custom property type's check() | setError($msg); return false; | throw KenedoValidationException::forProperty($this, $msg) | 🟡 false is still read as a refusal, but it can no longer carry a reason — it becomes "reported failed validation but gives no error message" |
Code calling validateData() | if (… === false) { getErrors(); } | try { … } catch (KenedoValidationException $e) { $e->getIssues(); } | 🔴 it never returns false any more. if (… === false) is now dead code that lets a refused save look like a successful one |
Code calling store() / copy() / delete() / publish() | false on any failure | returns its value, THROWS on any failure | 🔴 same — the false branch is unreachable, so an unconverted caller treats every failure as success |
Reading $model->getErrors() after a failure | the messages | fatal error, the method is gone | 🔴 catch the exception and read getMessages() / getIssues() |
setError() / setErrors() / getError() / getErrors() / resetErrors() | worked | removed from KenedoModel and KenedoProperty | 🔴 throw instead |
Rollback on a failed copy() / store() | driven by the false return | driven by the exception unwinding; transactions roll back identically | 🟢 no change needed |
| A 500's log identifier | — | meta.logIdentifier on the problem body | 🟢 new; the detail itself stays in the log |
| Where a nested failure happened | lost | meta.failedAt, outermost first | 🟢 new; every level a KenedoException passes through adds itself |
Extending KenedoModelLight | a model with an error bag and no constructor | deprecated — it is now an empty subclass of KenedoModel | 🔴 the four error methods are gone, and KenedoModel::__construct() requires a component: an override that skips parent::__construct($component) now throws |
catch (Exception) around a Kenedo write | caught most things | catch Throwable | 🔴 an Error (e.g. count(NULL) on PHP 8) is not an Exception; catching the narrower type skipped the ROLLBACK and left the savepoint stack off by one |
Why you should migrate rather than wait. A refusal you throw arrives at the operator with its
reason and its field intact, through any number of recursion levels. One you report with
setError() + false is flattened by the first layer that catches it, and what the operator sees
is an apology for a system error that never happened.
What breaks LOUDLY: calling setError(), setErrors(), getError(), getErrors() or
resetErrors() on a model or property is now a fatal error. That is deliberate — a silent no-op
would leave customization code believing it had reported something.
What breaks SILENTLY, and matters more: a caller that still tests if ($model->store($data) === false). Those methods do not return false any more, so the branch never runs and a failed save
reads as a successful one. Search your customizations for === false against store(),
copy(), delete(), publish() and validateData() and replace each with a try/catch.
Status: the conversion is complete. Every call is gone — KenedoModel, KenedoController,
KenedoProperty, all ten stock property types, all fourteen models that had them, and the
controllers and helpers that read them. KenedoSystemException is now genuinely thrown (it never
was before), and carries the identifier of the log entry holding its detail. The compatibility shim
in validateData() that read getErrors() after a false return is removed.
KenedoModelLight is deprecated and now an empty subclass of KenedoModel, so code naming it
still resolves. It was the last place in the component where the retired protocol survived; the six
models that extended it (cart, cartposition, configuratorpage, cleanup, product, productlist) never
used the error bag and are plain KenedoModels now.
Untouched on purpose, because they are separate error bags rather than this one:
ConfigboxJsonResponse::setErrors(), the KenedoPlatform implementations' internal bag, and
ConfigboxUserHelper::addError().
@see exceptions-and-validation.md — the playbook
2026-07-27 — the admin prefix is gone from model class names 🟢
What changed. Every ConfigboxModelAdmin<X> is now ConfigboxModel<X> — file names too
(models/adminquestions.php → models/questions.php). ConfigboxModelAdminReviews folded into the
existing ConfigboxModelReviews. Controllers keep their names; this is models only.
A shim keeps old callers working. KenedoModel::getModel('ConfigboxModelAdminQuestions') (any
casing) is rewritten to the new name and logged once per name to the deprecated log, so
customization code and frozen migration scripts run unchanged. Update at leisure; the log tells you
where.
The one thing to check in custom code: filter names. Filter keys derive from the model name, so
'adminquestions.page_id' is now 'questions.page_id' — the old key is silently ignored, not
shimmed (filters are data, not classes). Grep your customization for 'admin followed by a dot-key
and update. Error-message breadcrumbs ("copy of questions ID …") changed the same way.
| Area | Old | New | Status |
|---|---|---|---|
getModel('ConfigboxModelAdminX') | worked | works via shim, logged deprecated | 🟢 update at leisure |
class MyModel extends ConfigboxModelAdminX | worked | class is gone — extend ConfigboxModelX | 🔴 rename |
Filter keys 'adminx.field' | matched | match nothing | 🔴 rename |
x-configbox.model in schemas | ConfigboxModelAdminX | ConfigboxModelX | 🟢 regenerate types |
2026-07-26 — assignment lookups get a real API, and city_to_country is corrected 🟡
What changed. Every relation in ConfigboxCacheHelper::getAssignments() is now also available as a
named method on ConfigboxAssignmentsHelper (getProductIdForQuestion(),
getProductQuestionIds(), getZoneIdsForCountryId(), …). Core no longer reads the array anywhere; it
loads only the slice it needs and memoises per request instead of materialising the whole catalog.
getAssignments() is unchanged and still supported — same 24 keys, same element_* aliases, still
cached. Migrating is a quality move, not a forced one, and the guards disappear with it: point lookups
return null and collections return array(), so the undefined-key-becomes-fatal-500 trap goes away.
The one thing that actually breaks. city_to_country used to be keyed by county id as well as
city id — the build wrote each county's country into the city map. Cities and counties are separate
tables with separate auto-increments, so their ids overlap and a city lookup could return a county's
country, silently, for whichever ids collided. Counties now populate their own county_to_country
map and city_to_country holds only cities.
| Area | Old | New | Status |
|---|---|---|---|
Custom code reading any *_to_* assignment key | worked | still works, unchanged | 🟢 migrate at leisure |
Custom code reading city_to_country with a county id | returned the county's country | returns null | 🔴 use county_to_country / getCountryIdForCounty() |
Custom code reading city_to_country with a city id | could return a county's country on an id collision | always the city's country | 🟢 the bug is gone |
| Custom code working around that collision | needed | now wrong | 🔴 delete the workaround |
shippingmethod_to_zone via the new helper | one-element array | getZoneIdForShippingMethod() returns a scalar | 🟡 only if you migrate |
Full old-key → new-method table, the mechanical transform and a grep pack: assignments-lookups.md.
2026-07-20 — migration removes customization FK constraints on retiring core tables 🟡
What changed. The CB4 migration scripts now apply an explicit constraint policy to foreign keys that
customization side tables (#__configbox_external_*, the documented convention) hold on the three
retiring core tables:
3.5.2removes external FKs on#__configbox_elementsbefore renaming it to#__configbox_questions.3.5.3removes external FKs on#__configbox_xref_element_optionand#__configbox_optionsbefore dropping them. The xref case previously re-pointed such constraints to#__configbox_answers; it now removes them instead — re-creating constraints on schema the core does not own is brittle.
Removed constraints are not re-created. Data and id columns are untouched and keep matching the
successor tables (element_id → #__configbox_questions.id; xref-referencing columns →
#__configbox_answers.id, ids identical) — sites that want the integrity back re-add the FK from a
data/customization/updates/ script. Every removal is logged to configbox_upgrade_errors.log with
that instruction.
Tables outside the external_ convention are never touched. Their FKs follow the 3.5.1/3.5.2
renames automatically (InnoDB carries inbound constraints across a RENAME TABLE), but an FK on a
table 3.5.3 drops makes that DROP TABLE fail and freezes the migration — deliberately. The
script logs which constraint blocked it; the site removes/re-points it, then
configbox:migrate:unblock + configbox:migrate.
| Area | Old | New | Status |
|---|---|---|---|
external_* FK on the xref table | re-pointed to #__configbox_answers, rules preserved | removed, not re-created | 🟡 re-add from your own update script if wanted |
external_* FK on #__configbox_options | dropped | dropped (unchanged) | 🟡 re-link data via the option_id breadcrumb (§3.8) |
external_* FK on #__configbox_elements | carried across the rename | removed before the rename | 🟡 re-add against #__configbox_questions if wanted |
Non-external_ FK on a retiring table | xref/options: auto-handled; elements: carried | renames: carried (unchanged); drops: migration freezes until the site fixes it | 🔴 site's own responsibility |
Details: the "FKs may get removed" contract note in answers-collapse-deep-dive.md §3.8.
2026-07-20 — answer creation now refuses an orphan row 🔴
What changed. ConfigboxModelAnswers::store() rejects an insert whose question_id is
empty: it logs the reason to logs/configbox/ and returns false with a user-facing error. Updates are
unaffected, so pre-existing orphaned rows can still be repaired by assigning a question and re-saving.
Why. The option/answer collapse shims ConfigboxModelAdminoptions,
ConfigboxModelAdminoptionassignments and ConfigboxModelAdminxrefelementoptions onto the answers model.
That is correct for reads, but it made the pre-CB4 two-step creation idiom (create option → create xref
linking it to the question) silently write 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, on every create. question_id
is nullable and has no FK, so the database could not catch it either. This turns that silent data
corruption into a loud, immediate failure.
| Area | Old | New | Status |
|---|---|---|---|
| Two-step create via the shimmed option/xref models | wrote 2 answer rows, silently | insert refused, logged, store() returns false | 🔴 rewrite as a single adminanswers create with question_id set |
Rollback $optionModel->delete($optionId) after a failed two-step create | deleted the option row | that id is an answer id — can delete a live answer | 🔴 delete the rollback with the two-step create |
Insert with question_id set (any correct code) | worked | works | 🟢 no action |
Detection query for damage already written, plus the full narrative: §3.10 of answers-collapse-deep-dive.md.
2026-07-20 — core stops reading its own legacy cache aliases 🟢
What changed. Core now reads the canonical assignment-map keys (question_to_product,
page_to_question) instead of the deprecated element_to_product / page_to_element aliases it had
drifted onto — in ConfigboxCacheHelper::getQuestionData(), ConfigboxModelQuestions, and the
question and configurator-page views. The aliases are still populated, so nothing customization-side
changes; core reading its own deprecation shims was simply blocking their eventual retirement.
Two of those reads were also unguarded. The assignments map contains only published questions on published pages and products, so an unpublished or mid-creation question is legitimately absent — the undefined-key warning becomes a fatal 500 wherever warnings are exceptions (Magento developer mode). Now guarded.
| Area | Old | New | Status |
|---|---|---|---|
Custom code reading element_to_product / page_to_element | worked | still works (alias) | 🟢 update at leisure |
Direct $ass['question_to_product'][$id] reads in custom code | — | guard with isset() — absence is legitimate | 🔴 guard it |
2026-07 — configurator client-state store 🟢
The configurator page's client state moved from a write-once jQuery blob to a Redux-style store
(new AMD module configbox/store, assets/javascript/store.js). Previously the state lived in
#configurator-data's jQuery .data('json') and was essentially set once; now a single store owns it and
maintains it through interactions (selection round-trips update missingProductSelections, pricing,
per-question selections, …). Fully backward-compatible — nothing to change for most customizations.
| Area | Old | New | Status |
|---|---|---|---|
| Client state owner | write-once jQuery .data('json') blob on #configurator-data | configbox/store (immutable tree, dispatch/getState/subscribe/selectors) | 🟢 no action |
JS accessors configurator.getConfiguratorData / setConfiguratorDataItem / replaceConfiguratorData / getQuestionPropValue / questionHasProperty | read/wrote the blob directly | thin shims over the store (same signatures/behaviour) | 🟢 keep using them |
Reading #configurator-data data-json attribute directly | held the live-ish value | holds only the frozen initial seed; the store mirrors a fresh snapshot into the element's jQuery .data('json') on every change | 🟡 if you read the raw attribute for current state, read the store (or configurator.getConfiguratorData()) instead |
| New API | — | configurator.getStore() + the configbox/store module (actions, selectors, MERGE_SERVER_RESPONSE) | 🟢 optional, for new code |
Note: CBX's jQuery is AMD-scoped (cbj) — it is not a global (window.jQuery/$/cbj are
undefined on the page). Reach the store/jQuery via cbrequire(['configbox/store'], …).
2026-07 — element → question rename 🟡
What changed. The core configurator entity was called element in the code, DB and admin MVC but
question in the UI/frontend. CBX 4 finishes the transition — element → question everywhere:
the DB table #__configbox_elements → #__configbox_questions, every *element_id* and feature column, the
admin model/controller/view classes, the pricing/assignment cache keys, the ionCube rules/calc engine, the
rule/calc condition classes + their stored JSON, and the calc-formula DSL tokens. The entity's id is
unchanged, so carts/orders/rules/formulas/translations keep pointing at the right rows — only names changed.
What broke for customizations.
| Area | Old | New | Status |
|---|---|---|---|
| DB table | #__configbox_elements | #__configbox_questions | 🔴 update raw SQL |
| Question-id columns | element_id, element_id_a..d, column_element_id, row_element_id, multielementid | question_id, question_id_a..d, column_question_id, row_question_id, multiquestionid | 🔴 update raw SQL |
| Feature columns | element_custom_1..4, element_css_classes; order-config element_type/element_code; config label_element_custom_*, pm_*_show_element(price)s | question_* equivalents | 🔴 update raw SQL / property reads |
| Model class | ConfigboxModelAdminelements | ConfigboxModelQuestions | 🟢 getModel() aliases old→new |
| Admin controller/views | adminelements/adminelement (+ editor sub-views *_elementattribute) | adminquestions/adminquestion (*_questionproperty) | 🔴 update controller=/view=/getView() |
| Answer→question join prop | element_id (join adminelements), joinedby_element_id_to_adminelements_* | question_id (join adminquestions), joinedby_question_id_to_adminquestions_* | 🔴 update filter/prop names |
model_property_customization/ file | adminelements.php | still loads onto the renamed model | 🟢 getLegacyCustomizationAliases()=['adminelements'] |
| Pricing API | ConfigboxPrices::getElementPrice/…Recurring/getElementWeight | getQuestionPrice/…Recurring/getQuestionWeight | 🟢 legacy aliases kept |
| Assignment cache keys | element_to_product, product_to_element, element_to_page, page_to_element, answer_to_element, element_to_answer | question_to_* / *_to_question | 🟢 old keys kept as aliases |
| Pricing cache keys | calcModelByElement(+Recurring/Weight), taxClassIdByElement(+Recurring), regardingElement | *ByQuestion, taxClassIdByQuestion*, regardingQuestion | 🟢 old keys kept as aliases |
| Rule/calc classes | ConfigboxConditionElementAttribute, ConfigboxCalcTermElementAttribute | …QuestionProperty | 🔴 rename a custom subclass/ref |
| Rule/calc JSON | "type":"ElementAttribute", "elementId" | "type":"QuestionProperty", "questionId" | 🟢 migrated in stored data (incl. custom rule columns) |
| Calc DSL tokens | ElementAttribute(, ElementEntry(, ElementPrice(, ElementPriceRecurring(, RegardingElement( | QuestionProperty(, QuestionSelection(, QuestionPrice(, QuestionPriceRecurring(, RegardingQuestion( | 🟡 stock migrated; hard rename, no alias — external calc code you owe |
Not touched (do not rename): Joomla core #__extensions.element / #__updates.element; WordPress WPML
$translation->element_id in helpers/wordpress.php.
Playbook: element-question-rename.md — full before → after tables (DB /
model / views / pricing / caches / engine / DSL), the per-file decision procedure, worked conversions, and
the shim inventory. The DB migration ships in the core (helpers/updates/3.5.2.php, applied by
configbox:migrate) and does not touch data/customization/.
Core shims that do the auto-conversion (verify these):
KenedoModel::getModel()— aliasesConfigboxModelAdminelements→ConfigboxModelQuestions(logs a legacy call).ConfigboxModelQuestions::getLegacyCustomizationAliases()returns['adminelements']— keeps loading yourmodel_property_customization/adminelements.php.ConfigboxCacheHelperassignment build —$legacyAssignmentKeyAliases+$legacyPricingKeyAliasesre-expose the oldelement_*/*ByElement/regardingElementcache keys as aliases.ConfigboxPrices::getElementPrice()/getElementPriceRecurring()/getElementWeight()—@deprecatedaliases of thegetQuestion*methods.- No shim for the rule/calc
typestrings or the calc DSL tokens — those are a hard rename; the migration rewrites the stored data instead. The stored-rules migration is model-driven, so a customization's ownrule-typed column is converted too; calc/rule JSON you store in a table the core can't discover is a data migration you owe (data/customization/updates/).
2026-07 — Admin light / dark / auto theming 🟢
What changed. The CBX admin area now supports light / dark / auto colour schemes. The
deciding anchor is the standard data-bs-theme="light|dark" attribute (CBX's namespaced
Bootstrap 5.3 colour-mode system, scoped to .cb-content). On Joomla the Atum template already sets it on
<html> and CBX inherits it; on other platforms a new KenedoPlatform::getAdminColorScheme()
resolves the host preference and CBX stamps the attribute, with prefers-color-scheme as the
auto/OS fallback. Every hardcoded colour in the admin CSS was replaced with --cb-* custom properties
defined in the new assets/css/admin-theme.css (light defaults + a dark override + the auto block).
What broke for customizations. Nothing breaks functionally — light mode is pixel-identical, so existing custom CSS keeps rendering. But custom admin CSS/templates will look wrong in dark mode until they adopt the tokens:
| Area | Old | New | Status |
|---|---|---|---|
| Admin colours | hardcoded hex in admin.css / general.css / editors / properties/*.css | var(--cb-*) tokens in assets/css/admin-theme.css | 🟢 light unchanged |
Your custom.css (admin) | hardcoded hex | should reference --cb-* tokens so it flips | 🟡 works in light; hardcoded colours won't flip in dark |
| Third-party admin widgets (Tom Select, jQuery UI) you skin | hardcoded light colours | core ships dark overrides scoped to [data-bs-theme="dark"] .cb-content | 🟡 re-check any widget CSS you added |
| New platform method | — | KenedoPlatform::getAdminColorScheme() (interface + all 5 impls) | 🔴 a custom KenedoPlatform subclass must implement it |
What to do: in your admin custom.css, replace hardcoded colours with the --cb-* tokens (see the full
token list in assets/css/admin-theme.css) so your styling flips with the scheme; for any admin colour
that must differ in dark, add a [data-bs-theme="dark"] .cb-content <your-selector> { … } rule. Frontend
(configurator/cart) and PDF CSS are intentionally light-only — leave them. No dedicated playbook (it's
a token-adoption task, not a structural break); the tokens are self-documented in admin-theme.css.
Core mechanism: assets/css/admin-theme.css (the token palette + dark/auto blocks + vendor overrides),
KenedoView::getStyleSheetUrls() (loads it first on admin), KenedoView::getViewAttributes() (stamps
data-bs-theme off-Joomla), KenedoPlatform::getAdminColorScheme() (per-platform scheme resolution).
2026-07 — Answer/option model collapse 🟡
What changed. The two-table answer model was collapsed into one. The former "global option"
(#__configbox_options) and the per-question "answer/xref" (#__configbox_xref_element_option) became a
single #__configbox_answers table with a clean 1:n relationship to questions. The answer's identity
is its own id (unchanged from the old xref id), so carts, orders and rules kept working.
Migration status: ships and runs automatically. helpers/updates/3.5.3.php performs the whole collapse
(schema, data, #__configbox_strings + frozen cbcheckout_order_strings re-key, the
cbcheckout_order_configurations.xref_id → answer_id rename, dropping the two old tables) during the
package install/upgrade; 3.6.2.php backfills the answers.option_id breadcrumb on databases that were
collapsed before that column existed. Your customization's own tables are the exception — any FK into
#__configbox_options is dropped and the rows left untouched for you to re-link via answers.option_id.
See §3.8 of answers-collapse-deep-dive.md.
What broke for customizations.
| Area | Old | New | Status |
|---|---|---|---|
| Models | ConfigboxModelAdminoptions, ConfigboxModelAdminoptionassignments, ConfigboxModelAdminxrefelementoptions | ConfigboxModelAnswers | 🟢 getModel() aliases old→new |
| Admin controllers/views | adminoptions, adminoptionassignments, adminxrefelementoptions (+ singular) | adminanswers / adminanswer | 🔴 update controller=/view= URLs & links |
| DB tables | #__configbox_options, #__configbox_xref_element_option | #__configbox_answers | 🔴 update raw SQL |
| Answer title/description strings | #__configbox_strings keyed by option_id (types 5/15/60/61) | keyed by the answer id | 🔴 update getTranslation() keys |
The option_id join ("Reused Answer") | property on the answer model | removed (1:n, no shared option) | 🔴 remove references |
model_property_customization/ files | adminoptions.php, adminoptionassignments.php, adminxrefelementoptions.php | merged onto adminanswers | 🟡 auto-loaded; see playbook |
| External storage key | foreignTableKey => 'option_id' | => 'answer_id' | 🟡 remapped in code; table needs an answer_id column + data re-key — join #__configbox_answers.option_id to do it |
| Cache keys | *ByXref, xref_to_* / *_to_xref assignment maps | *ByAnswer, answer_to_* / *_to_answer | 🔴 update if you read the pricing/assignment caches |
| Pricing API | ConfigboxPrices::getXref*() | ConfigboxPrices::getAnswer*() | 🔴 rename calls |
| Frozen order config | cbcheckout_order_configurations.xref_id + .option_id | .answer_id (option_id dropped) | 🔴 update if you read order configs |
Playbook: model-property-customizations.md (covers the
model_property_customization/ files in full). Other kinds (overridden controllers/views referencing the
old names, raw-SQL customizations, cache/price-API consumers) follow the same old→new mapping in the table
above; dedicated playbooks will be added if a real customization needs one.
Core shims that do the auto-conversion (so you can verify the behaviour):
KenedoModel::getModel()— aliases the three old model class names toConfigboxModelAnswers(same pattern as the olderCbcheckoutModelOrder→ConfigboxModelOrderrecordalias). Logs a legacy call.KenedoModel::getCustomPropertyDefinitions()+getLegacyCustomizationAliases()— the answers model also loads the three legacymodel_property_customizationfiles and merges them, remappingforeignTableKey 'option_id' → 'answer_id'and dropping the removedoption_idjoin. A legacy file that errors (references a dropped table/deleted property) is logged and skipped, not fataled.ConfigboxModelAnswers::normaliseLegacyCustomPropertyDefinitions()— the per-model fix-ups.