Skip to main content
Version: 3.x

CBX 4 — breaking changes log (for customizations)

Version
3.x
Updated
View markdown

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 class view-<viewname> (on the getViewAttributes() wrapper it always sat next to); scripts that need the view NAME read data-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 CLASS property-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-id on 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 the name; 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 customizationWhat happensDo
CSS/JS selecting #view-<name>, #property-name-<prop>, #item-id-<n> or a filter idSelector matches nothing — styles vanish, scripts no-op silentlySwitch 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= attributeStill renders, but reintroduces the duplicate-id hazardDrop 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.subviewReadyFunctionsGone — TypeErrorDispatch or listen for cbx:view-ready instead 🔴
Hand-built list refresh URLsStill work, but write state under the view-name keyInclude 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 customizationWhat happensDo
Rule condition / calc term panel reading ->question_type (or ->element_type) off getQuestions() rowsRule editor 500s the moment your tab rendersResolve via ConfigboxQuestion::getQuestion() (try/catch) 🔴
JS type module registering fewer than nine handlersThe whole configurator page dies in initQuestions()Register through defineQuestionType() 🔴
configurator.registerQuestion() by nameWorks (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 customizationWhat happensDo
A question sub-class that already declares getSku() with an incompatible signature (no parameter, extra required parameters)Fatal on load — PHP rejects the incompatible overrideChange it to getSku($selection), or rename yours
A question sub-class that already declares getSku($selection) (or getSku($x)) as its own helperNo error — but core now calls it at checkout, so whatever it returns starts landing in option_skuCheck what it returns for a stored selection. If it was never meant for order lines, rename it
A system_overrides/ copy of ConfigboxModelOrderRecordKeeps the old $answer->sku read, so question sub-classes are ignored — silentlyPort 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.

AreaOldNewStatus
getProperties() on a host the property excludesproperty absentproperty 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 hostshides 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-platformnot possible — no propertyaccepted, 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 declarationthe declaration for the running platform🟡 code generating docs or schemas from it now varies per host — read $definition['required'] for the declaration
Generated schemasno platform informationx-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.

AreaOldNewStatus
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 immediatelystill 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 goingno way to askKenedoModel::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 pagesrefusedstill 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:

  1. A question or answer read after a render now holds its own columns. Code that relied on $answer->price being the live price after the view ran gets the raw column instead.
  2. 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:

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

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

  • elementImageSrc now carries the image URL instead of always being null — see the fix below.
  • elementImageCssClasses / elementImagePreloadAttributes are now always present ('' when the question has no image) instead of appearing only for questions that have one.
  • A question's cssClasses map no longer contains an empty-string key when the question has no custom classes, and the rendered class attribute loses the double space that produced.

Two fixes that ride along

  • Question illustrations were rendering with an empty src. views/question/tmpl/question_decoration.php reads elementImageSrc, which was declared and read but assigned by nothing, anywhere. $this->imageSrc now carries el_image_href. If a site relied on question images being invisible, they will now appear.
  • Cross-render leakage. ConfigboxQuestion::getQuestion() returns a clone, and a clone is shallow — every clone shared one set of ConfigboxAnswer objects. A second render in the same process therefore saw the first render's decoration: content modifiers applied twice, and a selected class left on an answer that had since been deselected. With nothing decorating the records, this cannot happen.

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.

EndpointOld answerNew answer
user::loginUser{success, errorMessage}, or a redirect for form posts200 + 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 keys200 + data:{id} + meta.feedback / 422 problem whose validationIssues use field (not fieldName) / 500 STORE_FAILED
cart::addProductToCart303 redirect to the cart or configurator page201 + data:{cartPositionId, complete, redirectUrl, cart?|missingSelections?}. The client navigates to data.redirectUrl
cart::removeCartPosition / editCartPosition / copyCartPositionredirect flowunchanged 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):

  1. 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 sentence
    server.request() resolves a normalised CbResponse for 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.
  2. Field-level display: issues from these endpoints carry field; the model's own arrays carry fieldName. customerform.js's displayValidationIssues() accepts both — code copying its old loop must read issues[i].field || issues[i].fieldName.
  3. Login/logout callers: branch on response.ok (the status), never on a success key or an errorMessage string. A failed login is a 401, not a 200-with-flag.
  4. 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 addProductToCart over HTTP must read the 201 envelope and follow data.redirectUrl itself.
  5. 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 placeholdershelpers/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).

TagOldNewStatus
SAVE_FILENAMEpersisted the filenamenow the default; tag removed from definitions🟢
NO_SAVE_FILENAMEopts out of persistingnew
PRESERVE_EXTinertremoved🟢
NOFILTERSAPPLYinertremoved🟢
SKIPDEFAULTFIELDinertremoved🟢

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.

AreaOldNewStatus
Record key for a translatable field$record->{'title-en-GB'}$record->title_en_GB🔴 rename in custom code
Form field name posted by the admintitle-en-GBtitle_en_GB🔴 custom templates must follow
Reading a value in a custom model/propertyhand-built $name.'-'.$tagKenedoLanguageHelper::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 keystitle-en-GBtitle_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.

AreaOldNewStatus
Custom property typeinherited storage silentlymust declare getStorageKind() / getDefaultDataType(), or generation warns🟡 works, but the generated column is a guess until you declare
Custom property definitionno storage keysmay set dataType, nullable🟢 optional; defaults come from the type
Boolean columnsmixed 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 columnsvarchar(16 … 1024), inconsistentvarchar(255)🟡 custom SQL is unaffected; custom schema assumptions may not be
Dropdown columnsassorted widthsvarchar(50)🟡 as above
Price-override blobsvarchar(1024)TEXT🟢 wider, nothing truncates
#__configbox_questions.maxval / minvalvarchar(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_idNULL allowedNOT NULL🔴 custom inserts must supply them
Foreign keys pointing at a changed columnrealigned 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:

ClassMeansAnswered with
KenedoValidationExceptionthe caller can fix it422, per-field issues
KenedoSystemExceptionthe caller cannot500, detail to the log only
KenedoExceptionbase of both
AreaOldNewStatus
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 failurereturns 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 failurethe messagesfatal error, the method is gone🔴 catch the exception and read getMessages() / getIssues()
setError() / setErrors() / getError() / getErrors() / resetErrors()workedremoved from KenedoModel and KenedoProperty🔴 throw instead
Rollback on a failed copy() / store()driven by the false returndriven by the exception unwinding; transactions roll back identically🟢 no change needed
A 500's log identifiermeta.logIdentifier on the problem body🟢 new; the detail itself stays in the log
Where a nested failure happenedlostmeta.failedAt, outermost first🟢 new; every level a KenedoException passes through adds itself
Extending KenedoModelLighta model with an error bag and no constructordeprecated — 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 writecaught most thingscatch 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.phpmodels/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.

AreaOldNewStatus
getModel('ConfigboxModelAdminX')workedworks via shim, logged deprecated🟢 update at leisure
class MyModel extends ConfigboxModelAdminXworkedclass is gone — extend ConfigboxModelX🔴 rename
Filter keys 'adminx.field'matchedmatch nothing🔴 rename
x-configbox.model in schemasConfigboxModelAdminXConfigboxModelX🟢 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.

AreaOldNewStatus
Custom code reading any *_to_* assignment keyworkedstill works, unchanged🟢 migrate at leisure
Custom code reading city_to_country with a county idreturned the county's countryreturns null🔴 use county_to_country / getCountryIdForCounty()
Custom code reading city_to_country with a city idcould return a county's country on an id collisionalways the city's country🟢 the bug is gone
Custom code working around that collisionneedednow wrong🔴 delete the workaround
shippingmethod_to_zone via the new helperone-element arraygetZoneIdForShippingMethod() 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.2 removes external FKs on #__configbox_elements before renaming it to #__configbox_questions.
  • 3.5.3 removes external FKs on #__configbox_xref_element_option and #__configbox_options before 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.

AreaOldNewStatus
external_* FK on the xref tablere-pointed to #__configbox_answers, rules preservedremoved, not re-created🟡 re-add from your own update script if wanted
external_* FK on #__configbox_optionsdroppeddropped (unchanged)🟡 re-link data via the option_id breadcrumb (§3.8)
external_* FK on #__configbox_elementscarried across the renameremoved before the rename🟡 re-add against #__configbox_questions if wanted
Non-external_ FK on a retiring tablexref/options: auto-handled; elements: carriedrenames: 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.

AreaOldNewStatus
Two-step create via the shimmed option/xref modelswrote 2 answer rows, silentlyinsert refused, logged, store() returns false🔴 rewrite as a single adminanswers create with question_id set
Rollback $optionModel->delete($optionId) after a failed two-step createdeleted the option rowthat 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)workedworks🟢 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.

AreaOldNewStatus
Custom code reading element_to_product / page_to_elementworkedstill works (alias)🟢 update at leisure
Direct $ass['question_to_product'][$id] reads in custom codeguard 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.

AreaOldNewStatus
Client state ownerwrite-once jQuery .data('json') blob on #configurator-dataconfigbox/store (immutable tree, dispatch/getState/subscribe/selectors)🟢 no action
JS accessors configurator.getConfiguratorData / setConfiguratorDataItem / replaceConfiguratorData / getQuestionPropValue / questionHasPropertyread/wrote the blob directlythin shims over the store (same signatures/behaviour)🟢 keep using them
Reading #configurator-data data-json attribute directlyheld the live-ish valueholds 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 APIconfigurator.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'], …).

See whats-new-for-devs.md.


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 — elementquestion 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.

AreaOldNewStatus
DB table#__configbox_elements#__configbox_questions🔴 update raw SQL
Question-id columnselement_id, element_id_a..d, column_element_id, row_element_id, multielementidquestion_id, question_id_a..d, column_question_id, row_question_id, multiquestionid🔴 update raw SQL
Feature columnselement_custom_1..4, element_css_classes; order-config element_type/element_code; config label_element_custom_*, pm_*_show_element(price)squestion_* equivalents🔴 update raw SQL / property reads
Model classConfigboxModelAdminelementsConfigboxModelQuestions🟢 getModel() aliases old→new
Admin controller/viewsadminelements/adminelement (+ editor sub-views *_elementattribute)adminquestions/adminquestion (*_questionproperty)🔴 update controller=/view=/getView()
Answer→question join propelement_id (join adminelements), joinedby_element_id_to_adminelements_*question_id (join adminquestions), joinedby_question_id_to_adminquestions_*🔴 update filter/prop names
model_property_customization/ fileadminelements.phpstill loads onto the renamed model🟢 getLegacyCustomizationAliases()=['adminelements']
Pricing APIConfigboxPrices::getElementPrice/…Recurring/getElementWeightgetQuestionPrice/…Recurring/getQuestionWeight🟢 legacy aliases kept
Assignment cache keyselement_to_product, product_to_element, element_to_page, page_to_element, answer_to_element, element_to_answerquestion_to_* / *_to_question🟢 old keys kept as aliases
Pricing cache keyscalcModelByElement(+Recurring/Weight), taxClassIdByElement(+Recurring), regardingElement*ByQuestion, taxClassIdByQuestion*, regardingQuestion🟢 old keys kept as aliases
Rule/calc classesConfigboxConditionElementAttribute, 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 tokensElementAttribute(, 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() — aliases ConfigboxModelAdminelementsConfigboxModelQuestions (logs a legacy call).
  • ConfigboxModelQuestions::getLegacyCustomizationAliases() returns ['adminelements'] — keeps loading your model_property_customization/adminelements.php.
  • ConfigboxCacheHelper assignment build — $legacyAssignmentKeyAliases + $legacyPricingKeyAliases re-expose the old element_* / *ByElement / regardingElement cache keys as aliases.
  • ConfigboxPrices::getElementPrice() / getElementPriceRecurring() / getElementWeight()@deprecated aliases of the getQuestion* methods.
  • No shim for the rule/calc type strings 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 own rule-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:

AreaOldNewStatus
Admin colourshardcoded hex in admin.css / general.css / editors / properties/*.cssvar(--cb-*) tokens in assets/css/admin-theme.css🟢 light unchanged
Your custom.css (admin)hardcoded hexshould 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 skinhardcoded light colourscore ships dark overrides scoped to [data-bs-theme="dark"] .cb-content🟡 re-check any widget CSS you added
New platform methodKenedoPlatform::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.

AreaOldNewStatus
ModelsConfigboxModelAdminoptions, ConfigboxModelAdminoptionassignments, ConfigboxModelAdminxrefelementoptionsConfigboxModelAnswers🟢 getModel() aliases old→new
Admin controllers/viewsadminoptions, 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 modelremoved (1:n, no shared option)🔴 remove references
model_property_customization/ filesadminoptions.php, adminoptionassignments.php, adminxrefelementoptions.phpmerged onto adminanswers🟡 auto-loaded; see playbook
External storage keyforeignTableKey => '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 APIConfigboxPrices::getXref*()ConfigboxPrices::getAnswer*()🔴 rename calls
Frozen order configcbcheckout_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 to ConfigboxModelAnswers (same pattern as the older CbcheckoutModelOrderConfigboxModelOrderrecord alias). Logs a legacy call.
  • KenedoModel::getCustomPropertyDefinitions() + getLegacyCustomizationAliases() — the answers model also loads the three legacy model_property_customization files and merges them, remapping foreignTableKey 'option_id' → 'answer_id' and dropping the removed option_id join. A legacy file that errors (references a dropped table/deleted property) is logged and skipped, not fataled.
  • ConfigboxModelAnswers::normaliseLegacyCustomPropertyDefinitions() — the per-model fix-ups.