Playbook: model-property customizations after the answer/option collapse
- Version
- 4.0 preview
- Updated
Applies to: files under data/customization/model_property_customization/ that customize the removed
option/answer models. Prerequisite reading: the general customization guides
../customization/com_configbox_extending_stock_models.md (the base mechanism) and ../customization/com_configbox_custom_properties.md
— both in the CBX developer docs (docs/customization/ in the platform integration repos, e.g.
the Joomla site repo).
1. What the mechanism is (recap)
A file model_property_customization/<base>.php defining a function
customPropertyDefinitions<Base>() gets merged into the model whose class is
ConfigboxModel<Base>. Example: adminanswers.php → customPropertyDefinitionsAdminanswers() →
ConfigboxModelAnswers. The loader is KenedoModel::getCustomPropertyDefinitions().
2. What changed in CBX 4
Three models were merged into one — ConfigboxModelAnswers (table #__configbox_answers):
| Removed model | Old file base | Was |
|---|---|---|
ConfigboxModelAdminoptions | adminoptions | the shared "global option" |
ConfigboxModelAdminoptionassignments | adminoptionassignments | the option↔question assignment editor |
ConfigboxModelAdminxrefelementoptions | adminxrefelementoptions | the per-question answer ("xref") |
So a customization file bound to any of those three bases now targets a model that no longer exists — the
adminanswers model would never load it.
3. What the core does for you automatically 🟢🟡
You do not have to move your code for the common cases. ConfigboxModelAnswers overrides two
hooks so the old files keep working:
// models/adminanswers.php
function getLegacyCustomizationAliases() {
return array('adminxrefelementoptions', 'adminoptions', 'adminoptionassignments');
}
KenedoModel::getCustomPropertyDefinitions() loads the answers model's own file plus each alias file,
merges their returned defs, and:
- Repoints external storage: any def with
storeExternallyandforeignTableKey => 'option_id'is rewritten toforeignTableKey => 'answer_id'(viaConfigboxModelAnswers::normaliseLegacyCustomPropertyDefinitions()). - Drops the removed join: a def keyed
option_id(the old "Reused Answer" join) is dropped with a log line — it has no equivalent on a 1:n answer. - Fails safe: if a legacy function throws (because it references a dropped table or a deleted property), it is logged to the CBX error log and skipped — the admin form still renders; you just lose that file's contributions until you fix them.
Additionally, KenedoModel::getModel() aliases the three old class names to
ConfigboxModelAnswers, so a legacy file line like
KenedoModel::getModel('ConfigboxModelAdminoptions')->getPropertyDefinitions() resolves to the answers
model instead of fataling.
Net effect: a cleanly-written option/answer customization keeps working untouched (with a deprecation line in the log). Only files that reach into something genuinely removed need your hand.
4. What you still must fix by hand 🔴
Anything the shim logs-and-skips. The tell is a line in the CBX error log:
Skipping customization function "customPropertyDefinitionsAdminxrefelementoptions" for model
"ConfigboxModelAnswers" - it errored (likely references something removed by a core refactor): ...
Fix them by consolidating into a single, correct adminanswers.php. The transforms:
4.1 Rename the file + function (the recommended end state)
Move your definitions into one model_property_customization/adminanswers.php with function
customPropertyDefinitionsAdminanswers(), and delete the three old files. This is the intended
destination: it drops the deprecation log lines, leaves one authoritative file instead of three, and is
what eventually lets core retire the alias shims.
The shim means the upgrade is not gated on doing this — getLegacyCustomizationAliases() keeps the
old filenames working deliberately, so deferring is legitimate if you have bigger things in flight. But
deferring is the only alternative; there is no reason to keep the old names permanently. When you do it,
two things fail silently if you do them half-way:
- The function name must move with the filename. The loader derives the function name from the
file's base name and silently skips the file when it is missing — no error, no log. Renaming
adminoptions.php→adminanswers.phpwhile leavingcustomPropertyDefinitionsAdminoptions()in place means your properties simply stop being registered and the admin form quietly loses fields. Rename both or neither. - You inherit the compatibility rewriting. Definitions loaded under a legacy base are passed
through
normaliseLegacyCustomPropertyDefinitions(), which repoints external storage from'foreignTableKey' => 'option_id'onto'answer_id'for you. That runs only for legacy bases — the model's own file is assumed correct. Once the file is calledadminanswers.php, do that repointing by hand (§4.4).
So: rename file and function together, hand-correct the foreignTableKeys in the same pass, then load
the admin form and confirm your custom fields still render. The one state to avoid is a partial
rename — file moved, function not — which silently deregisters your properties and is worse than either
having done it or having left it alone.
4.2 Fix references to removed models / properties
// BEFORE (adminoptions.php) — makes the option SKU required
$regularProps = KenedoModel::getModel('ConfigboxModelAdminoptions')->getPropertyDefinitions();
$propDefs['sku'] = $regularProps['sku'];
$propDefs['sku']['required'] = '1';
// AFTER (adminanswers.php) — sku now lives on the answer
$regularProps = KenedoModel::getModel('ConfigboxModelAnswers')->getPropertyDefinitions();
$propDefs['sku'] = $regularProps['sku'];
$propDefs['sku']['required'] = '1';
The option_id "Reused Answer" join is gone — there is no shared option, so any block that re-exports,
re-types (e.g. to bcoptionjoin), or relabels option_id must be deleted:
// BEFORE (adminxrefelementoptions.php) — DELETE THIS ENTIRE BLOCK
$originalDefs = KenedoModel::getModel(ConfigboxModelAdminxrefelementoptions::class)->getPropertyDefinitions();
$optionIdDef = $originalDefs['option_id'] ?? null;
if (!$optionIdDef) { throw new Exception('Could not find option_id property definition for overriding'); }
$optionIdDef['type'] = 'bcoptionjoin';
$propDefs['option_id'] = $optionIdDef;
4.3 Fix raw SQL against dropped tables
// BEFORE
$query = "SELECT `element_id` FROM `#__configbox_xref_element_option` WHERE `id` = ".intval($answerId);
// AFTER — note BOTH change: the table (collapse) and the column (element→question rename)
$query = "SELECT `question_id` FROM `#__configbox_answers` WHERE `id` = ".intval($answerId);
#__configbox_options is gone entirely — its columns (sku, price, weight, option_image,
option_custom_1..4, availability, was-prices, …) are now columns on #__configbox_answers. Rewrite any
options↔xref join to a plain read from #__configbox_answers.
4.4 Fix externally-stored fields (the data half)
The code half is auto-remapped (option_id → answer_id), but the external table itself is not. If a
def stores into e.g. #__configbox_external_option_appends keyed by option_id, that table needs:
- an
answer_idcolumn, and - its rows re-keyed from the old option id to the answer id.
Because one former option could be shared by several answers, this is a fan-out: each answer that used
option O gets its own append row copied from O's row. (This mirrors what the core answer migration did
for the title/description strings.) Deliver it as a data/customization/updates/ migration — see
../customization/com_configbox_extending_stock_models.md §"the migration that delivers the column" (in the CBX
developer docs). If you do not migrate the data, the fields render but read/write against a
missing/empty answer_id column.
4.5 Fix string keys (translatable custom fields)
Answer title/description/custom strings (#__configbox_strings types 5/15/60/61) are now keyed by the
answer id, not the option id. A customization that reads them directly must key by the answer id:
// BEFORE
ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->option_id);
// AFTER
ConfigboxCacheHelper::getTranslation('#__configbox_strings', 5, $answer->id);
The positionForm bands the core answer model uses for the absorbed option fields are 46000–47800;
place your inserted fields relative to those if you want them to sit inside the "Price, SKU and Weight" /
availability / description groups.
4.6 Stop using a property's absence as a platform test
'platforms' used to remove a property from getProperties() on every other host. It no longer
does — every property exists on every platform now (one schema, one set of migrations), and the key
only hides the field and drops its required where it does not apply. So any check shaped like
"is this property here?" answers yes everywhere from CBX 4:
// BEFORE — worked because the property really was absent off Joomla
$props = $model->getProperties();
if (isset($props['joomla_user_group_id'])) { … } // now ALWAYS true
// AFTER — ask the question you actually mean
if (KenedoPlatform::getName() === 'joomla') { … } // "am I on Joomla?"
if ($props['joomla_user_group_id']->appliesToThisPlatform()) { … } // "does this field apply here?"
Reading or writing such a field off-platform is safe rather than fatal — the column exists on every host, the value round-trips, and nothing consumes it. What changed is only that absence is no longer the signal.
If your own definition declares 'platforms', it keeps working and keeps its intent: not in the
form, never required. Add 'platformDefaults' => array('magento2' => 0) beside it when a host
also needs a new record to start at a different value — the per-platform if blocks that used to
set ['invisible'] and ['default'] together are both declarations now. Note that
getPropertyDefinition('default') resolves it, so that accessor answers for the running platform;
read $definition['default'] when you want the neutral declaration.
Full treatment: ../technical/com_configbox_property_definition_settings.md §11–12.
4b. If your customization introduces a property TYPE, it needs a template 🔴
A property definition that names a type core does not ship — 'type' => 'colorpicker' — is a custom
property type, and a custom property type without a template is a broken admin screen.
KenedoProperty::getBodyAdmin() resolves data/customization/properties/tmpl/<lowercase-type>.php,
falling back to core's external/kenedo/properties/tmpl/. If neither exists it throws, and because
the form renders its properties in one pass, one such property takes the whole edit screen down —
not just its own field. The symptom is a dead admin form, which reads like a much bigger problem than a
missing file.
The exception message names the three ways to satisfy it, and one of them must be true before you ship:
- ship
data/customization/properties/tmpl/<lowercase-type>.php; - override
getAdminTemplateFile()to reuse another type's template, when yours is a thin variant; - override
getBodyAdmin()and return the HTML yourself — for display-only properties or widgets built entirely in PHP/JS.
Watch for it especially when a property definition is copied between models during migration: the definition travels, the template does not, and the type only fails on the screen that renders it — which may not be the screen you were testing.
Older documentation described the template as optional, with a missing one rendering an empty body. That was wrong; it throws. See
../customization/com_configbox_custom_properties.md§2.3 for the full treatment.
5. Verification checklist
- Load an answer edit form (
controller=adminanswers&task=edit&id=<answerId>) — it renders, no white screen. - The CBX error log has no
Skipping customization function …line for your files. - Your custom fields appear on the form and their values save (check the external table gets an
answer_id-keyed row). - Every custom property type you introduce has a template, a
getAdminTemplateFile()override or agetBodyAdmin()override (§4b) — a missing one kills the whole form, not just the field. - No code of yours treats a property's absence from
getProperties()as a platform test (§4.6) — every property exists on every host now, so such a check is always true. - Grep your customization for the removed identifiers and confirm none remain in code you own:
xref_element_option,configbox_options,option_id,ConfigboxModelAdminoptions,ConfigboxModelAdminoptionassignments,ConfigboxModelAdminxrefelementoptions,getXref.
6. Reference: the exact old → new mapping
| Old | New |
|---|---|
ConfigboxModelAdminoptions / ...Adminoptionassignments / ...Adminxrefelementoptions | ConfigboxModelAnswers |
file adminoptions.php / adminoptionassignments.php / adminxrefelementoptions.php | adminanswers.php |
function customPropertyDefinitionsAdmin{options,optionassignments,xrefelementoptions}() | customPropertyDefinitionsAdminanswers() |
#__configbox_options, #__configbox_xref_element_option | #__configbox_answers |
foreignTableKey => 'option_id' | foreignTableKey => 'answer_id' |
option_id join property ("Reused Answer") | removed |
strings keyed by option_id (types 5/15/60/61) | keyed by the answer id |