# Playbook: model-property customizations after the answer/option collapse

> Applies to: files under data/customization/modelpropertycustomization/ that customize the removed option/answer models. Prerequisite reading: the general cus…

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/migration-to-cb4/model-property-customizations. Last updated 2026-08-11.

---
**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:

```php
// 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 `storeExternally` and `foreignTableKey => 'option_id'` is
  rewritten to `foreignTableKey => 'answer_id'` (via
  `ConfigboxModelAnswers::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:

1. **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.php` while leaving `customPropertyDefinitionsAdminoptions()` in
   place means your properties simply stop being registered and the admin form quietly loses fields.
   Rename **both** or **neither**.
2. **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 called `adminanswers.php`, do that repointing
   by hand (§4.4).

So: rename file and function together, hand-correct the `foreignTableKey`s 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

```php
// 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**:

```php
// 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

```php
// 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:

1. an **`answer_id` column**, and
2. 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:

```php
// 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:

```php
// 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:

1. ship `data/customization/properties/tmpl/<lowercase-type>.php`;
2. override **`getAdminTemplateFile()`** to reuse another type's template, when yours is a thin variant;
3. 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 a `getBodyAdmin()` 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 |
