Skip to main content
Version: 4.0 preview

CBX 4 — breaking changes log (for customizations)

Version
4.0 preview
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-09-07 — scroll offsets measure ALL pinned chrome, not the header 🟡​

configurator.getStickyHeaderHeight() is now configurator.getTopObstructionHeight(), and it answers a different question: not "how tall is the header" but "how much of the viewport's top is covered". Two changes behind the rename, both of which move the number your scrolls land on:

  • Pinned bars STACK, and all of them count. A covered band is grown downwards through the fixed/sticky bars (walked top-first): a bar joins the band when it starts inside what is covered so far, and extends it to its own bottom. A store pinning a 60px header with a 300px floating product visualization under it used to measure 60; it now measures 360. position: sticky counts only while pinned at the very top — further down it travels with the content, and absorbing it would swallow the page. The band is capped at 60% of the viewport height.
  • window.stickyHeaderHeight is a FLOOR, not the answer. It used to short-circuit the function and be returned verbatim. A template declares the static header it knows about at page load; the block that actually covers the question often appears only on scroll. The measurement now starts from the declared value and may exceed it.

New alongside it: getBottomObstructionHeight() (the pinned totals/next bar — content under it is not visible either) and getPinnedBars() (the raw bars, if you need your own arithmetic).

Your customizationWhat happensDo
Calls configurator.getStickyHeaderHeight()keeps working — kept as a deprecated alias delegating to getTopObstructionHeight(), so you get the better measurement without editing anything. The alias is silent (no console warning: it is called during scrolling)rename at leisure
Declares window.stickyHeaderHeight and expects that exact value backgets a number ≥ what it declared, whenever the page pins anything below the headernothing, if the declaration was meant as "clear my header" — that still holds. If it was a deliberate cap, it no longer is; there is no cap knob
A custom page flow (accordion, wizard, tabs) that scrolls the step it revealsworks, but probably carries the same two defects the shipped flow did — see belowapply both edits
Reads configurator.getPinnedBars()-shaped data of its ownunaffected — new API, nothing removednothing

The two defects a copied scroll almost certainly has. The shipped accordion flow (customization/assets/javascript/accordionflow.js in cbx-joomla) had both, and any flow that copied the core's scroll convention has them too:

  1. Its correction pass called scrollTo({behavior: 'auto'}), meaning to jump. 'auto' follows CSS scroll-behavior, which templates set to smooth — so the correction animated from wherever the first scroll had reached, and could be overtaken by the next re-render. Use 'instant'.
  2. Its "already fully visible, leave it alone" test compared rect.bottom <= window.innerHeight, so a step sitting behind the pinned totals bar counted as visible and the correction never fired. Compare against window.innerHeight - configurator.getBottomObstructionHeight().

Kind: 🟢 for the rename (aliased) · 🟡 for page flows and anything that copied the scroll code — those are manual edits, and nothing warns you.

grep -rnE "getStickyHeaderHeight|stickyHeaderHeight|scrollTo\(|behavior: *['\"]auto['\"]|innerHeight" data/customization/assets/javascript/

EFKA specifically: accordionflow.js is mounted as Rovexo_ConfigboxCustomizations/view/base/web/javascript/accordionflow.js. cbx-joomla's copy has been updated (new names + both fixes above); the Magento copy has not — it keeps working on the alias, but still corrects with 'auto' and still ignores the bottom bar. Port the two edits.

Data half: none — this is frontend JS only, no schema and no stored value changes.

Playbook: custom page flows: the reveal contract, and the chat advisor's scroll for the full rules.

2026-09-07 — the question type is fixed once a question is saved 🟡​

question_type was an ordinary dropdown: a saved radio-buttons question could be turned into a text box, a calendar or a slider. Its answers, its rules and formulas (which read fields the old type declared), its type-specific settings and every stored selection then referred to a kind of value the question no longer produces — a rule that never holds, a formula on its fallback, a cart line the type cannot render. The type is now decided at the first save. Afterwards the model accepts only a change to a type the stored type's class lists as compatible — ConfigboxQuestion::getCompatibleTypes(), a static method per type class: the four answer-taking types among each other (radiobuttons, dropdown, images, checkbox — the selection stays an answer id), the three text types among each other (textbox, textarea, choices); every other stock type is fixed. The check is KenedoPropertyQuestiontype::check() on the model, so the form, the entity API, the MCP tools and the product transfer are refused alike, with an issue on question_type (QUESTION_TYPE_CHANGE_REFUSED / QUESTION_TYPE_CHANGE_REFUSED_COMPATIBLE). The form shows only the compatible types on a saved question, and the type as plain text when there are none.

Your customizationWhat happensDo
A custom question type (ConfigboxQuestion<Type>)fixed once saved — the base class declares no compatible typesdeclare public static function getCompatibleTypes() returning the type names it may become, if any. Two types are compatible when a stored selection means the same thing to both
A model_property_customization/questions.php that redefines question_type with 'type' => 'dropdown'the lock is gone — the property is an ordinary dropdown againkeep 'type' => 'questiontype' (the property type that carries the lock) and change only the keys you need
An importer, fixture or script that creates a question and sets its type in a second writethe second write is refused unless the two types are compatibleset the type in the create; to change the kind of a question, create a new one
Product transfer packagesunaffected — an import creates questions, it does not retype existing onesnothing

Kind: 🟡 manual for custom types (declare what they may become) and for writers that retype after the fact · 🟢 everything else.

grep -rnE "question_type" data/customization/model_property_customization/ data/customization/classes/ data/customization/system_overrides/

Data half: none — existing questions keep their type, nothing is rewritten.

Playbook: custom question types, §2.9 "Two more things a type may declare" (getCompatibleTypes()).

2026-09-07 — question conditions compare by field kind: an unanswered number is no longer 0 🟡​

The question condition (ConfigboxConditionQuestionProperty) compared everything the way the old ElementAttribute did — version_compare(floatval($is), floatval($should)), with an unanswered question reading as 0. It now asks the field's kind (ConfigboxQuestionField::matches()):

BeforeNow
Unanswered question, ordering operator (<, <=, >, >=)compared as 0 — "Width < 5" held while Width was still emptyfalse — nothing there is nothing to order
Unanswered question, == / != a valuecompared as 0== false, != true
Empty condition valuecompared as 0means "not answered": == holds while unanswered, != while answered, orderings never
Numbersversion_compare — 2.10 > 2.5numerically — 2.5 > 2.10, with a relative tolerance so 0.1 + 0.2 == 0.3
Dates (a calendar question's selected)as version stringsby calendar day
Choices (an answer id, a slot name) and textsas version stringsas strings, == / != only; an ordering stored by an older editor falls back to numbers

The first row is the one that changes outcomes on an existing catalogue: a rule that showed or hid something on "a number question below a threshold" used to hold before the customer typed anything. Find such rules with the audit below and decide per rule whether "not answered" should count — if it should, add the question's "not answered" chip with OR in the editor (it stores an empty value, which is exactly that case).

Your customizationWhat happensDo
Stored rules with an ordering on a number or date field that relied on unanswered = 0no longer hold while the question is unansweredaudit (below); add the "not answered" chip where the old behaviour was wanted
A custom question type's getFieldValue() that returns 0 or '' for "nothing there"0 compares as a number, '' as unansweredreturn null for nothing there — a quantity of zero and no quantity are different things to a rule
A custom condition class with a comparison of its ownuntouched — the change is inside the question conditionnothing; if you copied version_compare from ElementAttribute, consider ConfigboxQuestionField::compare()

Kind: 🟡 a behaviour change on stored data; no code breaks.

-- the rules with an ordering operator, to read through once (the same for #__configbox_answers)
SELECT id, rules FROM #__configbox_questions WHERE rules LIKE '%"operator":"<%' OR rules LIKE '%"operator":">%';

Data half: nothing rewritten.

Playbook: the rule engine §4.3 and the kinds table in custom question types, §2.6 "Rules and formulas — the type declares its fields".

2026-09-07 — the Joomla installer removes files this version no longer ships 🟡​

A Joomla package install copies the new files and deletes nothing, so an upgrade left every file of the old version in place that the new one did not overwrite. ConfigBox discovers some of its parts by scanning directories — rule condition types, calc term types, payment providers — and on a 3.4.4 site upgraded to 3.8.28 the retired ConfigboxConditionElementAttribute.php was still in place: the rule editor listed it as an extra "Answers to questions" category with the old question picker under it. helpers/retired_files.txt names every retired file and directory (one path per line, relative to components/com_configbox, a trailing slash for a directory); the installer script removes them after the copy, best effort and logged, and tools/make-package.sh refuses to build a package that still ships one of them.

Your customizationWhat happensDo
Lives in data/customization/untouched — the list names paths under components/com_configbox onlynothing
Patched a core file that is on the list (an admin-prefixed model, helpers/calculation.php, a retired view)the file is deleted at the next upgrademove the change into the customization layer — system_overrides, model_property_customization, templates
requires a listed file (classes/datahelpers/ConfigboxListingData.php, external/geoip2-2.13.0/, assets/kenedo/external/jquery-3.6.1/)fatal after the upgradedrop the include and use the current equivalent next to it
Deploys by unpacking the zip over the tree instead of installing the packagenothing is removeddelete the listed paths yourself — the list is the contract

Kind: 🟡 manual only for code that reaches into a retired core file.

grep -rnE "geoip2-2\.13|ConfigboxListingData|helpers/(calculation|rules|configurator|addon|shapediver)\.php|models/admin[a-z]+\.php|jquery-3\.(4|6)\.1|tinymce-5\.7\.1" data/customization/

Data half: none.

Playbook: the Joomla platform guide §3, the install contract.

2026-09-07 — configurator page template overrides: $this->pricingView is back, missingPageSelections is a list 🟢​

A client template for views/configuratorpage/ (in data/customization/templates/configuratorpage/) that renders the pricing block a second time — a running total next to the full overview, say — used $this->pricingView on 3.4.x. The property had gone missing along the way, and such a template died with "Call to a member function on null" after the upgrade. It is a public property again: the ConfigboxViewBlockpricing instance that rendered $this->selectionsHtml, so $this->pricingView->getViewOutput('<layout>') renders it once more with another layout. $this->missingPageSelections is a plain list (array_values) of the page's missing required selections, so count(), foreach and json_encode behave as on a list.

Kind: 🟢 restored; nothing to change unless you worked around the missing property.

2026-09-06 — what a rule or formula can read off a question is declared by the question type; per-type condition and term classes are superseded 🟡​

A condition about a question — its answer, its entry, its price, a member of a composite selection such as the width of a dimensions question — used to need a condition class per question type when the stock question condition could not express it, a term class for the formula side, and an editor sub-view for each. The question type now declares its fields once — getRuleFields(), getCalcFields() and getFieldValue($key, $selection, $answerId, $selections) on ConfigboxQuestion<Type> — and the one generic question condition (ConfigboxConditionQuestionProperty) and term (ConfigboxCalcTermQuestionProperty) render, validate, evaluate, copy, transfer and describe them. The editors are question-first: the chips sit under the question, grouped by the field's group; condition classes are listed under "Other conditions", term classes under their own headings.

Nothing stops working — this entry is here because the class you may have written is now the long way round, and because the AI authoring tools describe fields, not classes.

Your customizationWhat happensDo
A condition or term class of your own about a question (CustomConditionWidthOf…, CustomCalcTermAreaOf…)still discovered by the directory scan, still evaluates the rules and formulas stored with its type, listed under "Other conditions"declare the same thing as fields on the type, rewrite the stored items of your type into question items, delete the class — the recipe is in the playbooks' §0
A condition or term class that is not about a question (weekday, customer group, stock level)unchangednothing
Editor sub-views of your own, views/adminruleeditor_<type>/ and views/admincalcformula_<type>/still rendered for your own type; the core QuestionProperty ones are goneremove together with the class
cbx_describe_rules / cbx_describe_calculations for an assistantthe question's fields are described from the declaration; a custom class's own keys are not described at alldeclare fields to be described

Kind: 🟡 superseded, not broken.

grep -rnE "^class Custom(Condition|CalcTerm)" data/customization/rule_condition_types/ data/customization/calc_term_types/

Data half: the rules and formulas stored with your type are yours to rewrite — with ConfigboxRulesJsonHelper::walk() from a customization update script (playbook §0).

Playbook: custom question types, §2.6 "Rules and formulas — the type declares its fields" · custom rule conditions §0 · custom calc term types §0.

2026-09-06 — one stored vocabulary for question conditions and terms: selected, field, flat 🟢​

Rule conditions and formula terms about a question named the selection three ways (selectedAnswer.id, selection, selected), the field two ways (field in a rule, fieldPath in a term) and, in data written before 3.6, nested the fields under a data wrapper. One vocabulary now:

{"type":"QuestionProperty","questionId":42,"field":"selected","operator":"==","value":"57"}

answerId sits next to field for a field declared per answer; a term carries fallbackValue instead of operator/value; questionId is an int, except a term's "regarding" marker.

OldNew
"field":"selectedAnswer.id", "field":"selection""field":"selected"
"fieldPath":"…" (terms)"field":"…"
{"type":"QuestionProperty","data":{…}}the keys at the top level
"questionId":"42""questionId":42
Your customizationWhat happensDo
Rules in #__configbox_questions.rules and #__configbox_answers.rules, formulas in #__configbox_calculation_formulas.calcrewritten by migration 3.8.26nothing
A rule-typed property of your own — on a model of your own, or added to a core model through model_property_customizationrewritten by migration 3.8.29, which walks the models the way 3.5.2 didnothing — the migration log has a 3.8.29: rewrote N of M … line per column
Rule JSON in a table of your own that no model declaresnot rewritten — but the question condition and term now normalize every item on read, so it evaluates correctly anywayrewrite it once from a customization update script: ConfigboxRulesJsonHelper::normalizeColumn('#__my_table', 'rules', 'my rule', '1.2.0');
Code that writes conditions or terms — importers, fixtures, the data-* attributes of a custom chipthe old names are read correctly, but the editors, the API, the transfer and the AI tools expect the new ones, and a re-save through the rule editor stores what the chip saysemit selected and field; a chip: data-field="selected"
Code that reads a stored item and switches on the field (case 'selectedAnswer.id':)sees selected after the migrationread selected, or run the item through ConfigboxRulesJsonHelper::normalizeItem() first

Kind: 🟢 auto-converted — migrations 3.8.26 and 3.8.29, and normalization on read in the two question type classes · 🟡 for writers.

grep -rnE "selectedAnswer\.id|['\"]selection['\"]|fieldPath" data/customization/

Data half: 3.8.26 (core columns), 3.8.29 (every rule column a model declares), ConfigboxRulesJsonHelper::normalizeColumn() for a table of your own. Every rewrite is counted in logs/configbox/configbox_upgrade_errors.log; a value that does not decode as JSON is left as it is and named there.

Playbook: the cheat sheet §7.

2026-09-06 — ConfigboxPrices::getQuestionPrice() and the other question and answer price helpers take the selection set 🔴​

getQuestionPrice($questionId, $getNet, $inBaseCurrency) priced the live configuration — the session's selections — whoever called it. A rule evaluated on a preview, a formula term, a read for another cart position all priced the wrong set, and the mistake was invisible on a dev site where the live set is the one being looked at. The helpers now take the selections as a required argument:

BeforeNow
getQuestionPrice($qId, $getNet, $inBase)getQuestionPrice($qId, array $selections, $getNet, $inBase)
getQuestionPriceRecurring($qId, $getNet, $inBase)getQuestionPriceRecurring($qId, array $selections, $getNet, $inBase)
getQuestionWeight($qId)getQuestionWeight($qId, array $selections)
getAnswerPrice($aId, $qId, $getNet, $inBase)getAnswerPrice($aId, $qId, array $selections, $getNet, $inBase)
getAnswerPriceRecurring($aId, $qId, $getNet, $inBase)getAnswerPriceRecurring($aId, $qId, array $selections, $getNet, $inBase)
getAnswerWeight($aId)getAnswerWeight($aId, array $selections)
getElementPrice(), getElementPriceRecurring(), getElementWeight() (deprecated aliases)unchanged — default to the live set
Your customizationWhat happensDo
Calls ConfigboxPrices::getQuestionPrice($id, true) or any of the six above with the old aritya TypeError / ArgumentCountError at call time (PHP 8 does not coerce a bool into an array)pass the set — ConfigboxConfiguration::getInstance()->getSelections() for the live one — or call ConfigboxQuestion::getQuestion($id)->getPrice() (and ->answers[$answerId]->getPrice() for an answer), which default to the live set
Calls the deprecated getElement*() aliaseskeep working with the live defaultmigrate when convenient
A custom condition or term class that prices a question while evaluatingreceived $selections all along — pass that set, never the live oneuse the $selections argument of getEvaluationResult() / getTermResult()

Kind: 🔴 manual — a fatal at call time, so it is found on the first request that reaches it.

grep -rnE "ConfigboxPrices::get(Question|Answer)(Price|PriceRecurring|Weight)\(" data/customization/

Data half: none.

Playbook: the cheat sheet §4.

2026-09-06 — an answer says whether its price is static or calculated: price_calculated, price_recurring_calculated 🟢​

The answer form showed the static prices and the price calculations side by side, and the engine ran the calculation whenever one was set. Two columns make the choice explicit — migrations 3.8.27 and 3.8.28: price_calculated 0 prices the answer with price and its group overrides, 1 with calcmodel and its calculation overrides; price_recurring_calculated does the same for the recurring price. The backfill marked an answer with a calculation set as calculated, once, together with adding the column.

Your customizationWhat happensDo
Writes an answer's calcmodel through the model, the entity API or a transfer package without the switchConfigboxModelAnswers::store() infers the switch from calcmodel / calcmodel_recurring when it is not sent (or sent at its default on an insert)nothing; send the switch explicitly to store a calculation without using it
Sends the switch on an updatethe admin's word — a calculation that is set does not flip it backnothing
Raw SQL inserts into #__configbox_answers with a calcmodelthe switch stays 0 and the static price appliesset price_calculated = 1 (price_recurring_calculated for the recurring one)
Reads the pricing cache expecting a set calcmodel to always applya calculated price applies only while its switch is 1read the switch
A model_property_customization/answers.php that hides the price groups with appliesWhenthe core price fields carry appliesWhen on the switch nowkeep yours — both apply

Kind: 🟢 auto (backfill and store() inference) · 🟡 raw SQL writers.

Data half: 3.8.27 and 3.8.28, each only together with adding its column.

2026-08-19 — a customization's own child records now travel in transfer packages, and land 🟡​

A childentries you hang off a product has always been collected by a transfer export — the export walks the record graph, so your records went into the package. They could not come out again: the importer compared against a fixed six-model const, so a site with such a relation produced packages that could not be imported anywhere, including back into itself. Four things refused them (the const, a fixed parent-key list, the file property's write, and pass-two's column update); all four are fixed, and the round trip works.

What changes for you. Records that previously never arrived now do.

BeforeNow
Your childentries records in a packagecollected, then refused at importcreated on the target
Which models an import may createliteral const, six core modelsderived from the graph: reachable from Products through childentries that pass the export's own gates
Parent key of your child recordsNULL → "Column 'x' cannot be null"taken from the owning property's foreignKeyField

🟡 The one thing to check. exportWithParent defaults to true, so a childentries whose records are local to this site — a log, a usage history, an audit trail — will now be written into the target install. It was already leaving your site inside the package; the difference is that it now lands. Set 'exportWithParent' => false on that property (the same setting the copy and delete walks read as copyWithParent / deleteWithParent).

A relation is only walked at all if it names a parent relation — viewFilters, foreignKeyField and viewClass all present. One that does not is skipped, as it always was: without a filter, "the children of this record" is every record of the child model.

Safety is unchanged. An import still cannot create a tax class, a customer group or a salutation: those are references, not childentries of a product, so they are resolved by name and never conjured. That property was what the const protected, and the derived set preserves it.


2026-08-19 — a side-table value column must be NULLABLE, or saves of that model fail 🔴​

Not a new rule, but the first time it is written down where a porter will look: it decides whether your customization's own columns work at all, and it fails in a way that names the wrong culprit.

KenedoProperty::store() writes every declared column of a side table on every save — a radio question's save still writes the cards and dimensions columns, because appliesWhen governs the admin form, not the write. A value that is absent or empty is written as NULL. So:

ADD `dim_cut_warning_enabled` VARCHAR(1) NOT NULL DEFAULT '1' -- WRONG, and not obviously so
ADD `dim_cut_warning_enabled` VARCHAR(1) NULL DEFAULT '1' -- what to write

The DEFAULT does not save you. A default applies when the column is omitted from the statement, and store() never omits it — it names the column and passes NULL. The save is refused outright with "Column 'dim_cut_warning_enabled' cannot be null", and the record that fails is usually one your property does not even apply to.

Where it bites hardest: importing a transfer package built on a site whose customization layer does not declare the field. The package carries no value, the importer stores NULL, and the whole product rolls back on the first question.

The matching rule for the record tables of a customization's own models is the opposite and still holds: their own columns (published, ordering, a filename) are legitimately NOT NULL. This is about value columns of property side tables only.

Two related fixes landed the same day, so a partial record no longer fails on a technicality: store() treats a property that is not set at all the same as one set to '' (it used to raise "Undefined property", which on Magento is a fatal, not a notice — see the CHEATSHEET's warnings-as-exceptions note), and a non-external file property now UPDATEs its column instead of INSERT … ON DUPLICATE KEY UPDATE, whose INSERT half MySQL validates even when the duplicate is certain, so a NOT NULL parent key with no default refused the write.


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

TagOldNewStatus
SAVE_FILENAMEpersisted the filenamenow the default; tag removed from definitions🟢
NO_SAVE_FILENAME—opts 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 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:

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 identifier—meta.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.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.

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

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 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'], …).

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

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 ConfigboxModelAdminelements → ConfigboxModelQuestions (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 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.

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 CbcheckoutModelOrder→ConfigboxModelOrderrecord 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.