# Extending Stock Models (adding/overriding fields)

> How to add a field to an existing CBX model — or tweak how an existing field appears — without editing the core model file. This is the modelpropertycustomiz…

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/customization/extending_stock_models. Last updated 2026-08-25.

---
How to **add a field to an existing CBX model** — or tweak how an existing field appears — without
editing the core model file. This is the `model_property_customization/` mechanism: you contribute extra
**property definitions** that the framework **merges** into a stock model's own definitions. Because a
model's property definitions drive its form, its listing, its SQL and its storage (see the Kenedo MVC
doc), one definition added here gives you a new form widget, an optional list column, request parsing,
validation, the SELECT and storage — all without touching the model.

This is the **merge**-style extension point (your defs are combined with the core's, not substituted for
the whole model). It pairs with `com_configbox_custom_properties.md`: that guide builds a new *field type*;
this guide *uses* a type (built-in or custom) on an existing model.

Read `com_configbox_customization_overview.md` first. All paths are relative to the component root
`docroot/components/com_configbox/`. Source references are point-in-time — verify against the code.

---

## 1. When to use this (vs. shadowing the model)

| You want to … | Use |
|---|---|
| **Add a field** to a stock model's form/list | **This mechanism** (`model_property_customization/`) |
| **Change how a stock field appears** (list position, hide from listing, tooltip, order) | **This mechanism** (re-declare the field by `name`) |
| Add a **new field type/widget** that doesn't exist yet | `properties/` — `com_configbox_custom_properties.md` |
| Change a model's **behavior** (a method, a query, validation logic beyond a property) | A **`system_overrides/` class replacement** — the customization `models/` folder can't shadow a core model (core-first). See `com_configbox_overriding_controllers_and_models.md` |

Prefer this mechanism whenever the change is "data shape" rather than "behavior": it is additive, it
doesn't copy the core model (so it never drifts out of sync), and it survives updates cleanly. For genuine
**behavior** changes note that you **cannot** shadow a core model by dropping a same-named file in
`data/customization/models/` — that loader is **core-first** (it loads the customization file only for
*new* model names). Changing a core model's methods means a `system_overrides/` class replacement; see the
controllers-and-models guide.

---

## 2. The contract (file name, function name, return value)

The merge is implemented in `KenedoModel::getCustomPropertyDefinitions()`
(`external/kenedo/classes/KenedoModel.php`). For a model class `ConfigboxModelProducts`:

```php
$ownBase  = strtolower( substr(get_class($this), strpos(get_class($this), 'Model') + 5) ); // "products"
$path     = getDirCustomization().'/model_property_customization/'.$ownBase.'.php';         // products.php
include_once($path);
$function = 'customPropertyDefinitions'.ucfirst($ownBase);                                  // customPropertyDefinitionsProducts
$defs     = $function();                                                                    // your defs
```

So the contract has **three exact parts**:

| Part | Rule | Example (`ConfigboxModelProducts`) |
|---|---|---|
| **File** | `data/customization/model_property_customization/<modelname>.php`, where `<modelname>` is the class name **lowercased**, with the leading `ConfigboxModel` removed | `…/model_property_customization/products.php` |
| **Function** | `customPropertyDefinitions<Modelname>()` — note `<Modelname>` is `ucfirst()` of the file base (so the **first letter uppercase**, the rest as-is) | `function customPropertyDefinitionsProducts()` |
| **Return** | an array of property definitions, **keyed by each def's `name`** | `return $propDefs;` |

> **Finding the model name:** it's the class after `ConfigboxModel` — model classes carry **no Admin
> prefix** (`ConfigboxModelProducts` in `models/products.php`, `ConfigboxModelQuestions` in
> `models/questions.php`) → file `products.php`, function `customPropertyDefinitionsProducts`. Get the
> casing of the function wrong and the framework silently skips your file (`function_exists()` check) —
> no error, your field just doesn't appear. This is the #1 mistake; double-check it.

Two behaviours of the current loader worth knowing:

- **Legacy aliases.** A model that absorbed or renamed another one lists its old file bases in
  `getLegacyCustomizationAliases()`, so a customization file from the retired admin-prefixed era
  (`adminproducts.php`) still loads — the model's own canonical file wins on key collisions. New code
  always targets the canonical name.
- **Defensive loading.** A customization function that throws (typically because it references
  something a core refactor removed) is **logged and skipped**, not fatal — the admin keeps working
  without your fields rather than white-screening. Check the CBX log when injected fields vanish
  after an update.

---

## 3. How the merge works (add vs. override by `name`)

`getProperties()` merges your defs into the model's own (`KenedoModel.php`):

```php
$propDefs       = $this->getPropertyDefinitions();        // core defs, keyed by name
$customPropDefs = $this->getCustomPropertyDefinitions();  // your defs, keyed by name
$propDefs       = array_merge($propDefs, $customPropDefs); // ← custom wins on key collision
uasort($propDefs, array('KenedoModel', 'sortProperties')); // re-sorted by positionForm
```

Two outcomes follow from `array_merge` keying on `name`:

- **A `name` the core model doesn't have → the field is *added*.**
- **A `name` the core model *does* have → your def *replaces* the core def for that field, wholesale.**
  (It's a replace, not a deep-merge: whatever keys you return become the field's full definition.) The
  common, safe pattern for "I only want to tweak one thing" is to **clone the original def, then change
  it** — see §5.

After merging, all defs are re-sorted by `positionForm`, so a newly added field lands wherever its
`positionForm` places it in the form (and `positionList` in the listing).

---

## 4. Worked example — add a "Care instructions" field to products

Add a multi-line text field to the **product** admin form, store it in a side table (so no core table is
altered), and show it as a sortable list column. The field uses the built-in `string` type — no custom
property type needed.

### 4.1 The schema (migration first)

A property only *maps* storage; it never creates it. Deliver the column via a **customization migration**
(`data/customization/updates/<version>.php`), guarded for idempotency. Here we add a side table so we
don't touch the core product table:

```php
// data/customization/updates/0.0.1.php   (customization migration track)
defined('CB_VALID_ENTRY') or die();

if (ConfigboxUpdateHelper::tableExists('#__configbox_product_care') == false) {
    $db = KenedoPlatform::getDb();
    $db->query("
        CREATE TABLE `#__configbox_product_care` (
            `product_id` INT UNSIGNED NOT NULL PRIMARY KEY,
            `care_instructions` TEXT NULL
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
    ");
}
```

> **Spell out both charset AND collation** — exactly as above, matching
> `ConfigboxUpdateHelper::TARGET_CHARSET`/`TARGET_COLLATION`. `CHARSET=utf8mb4` *without* a
> `COLLATE` clause does not inherit the database's collation; it takes the charset's
> server-dependent default (`utf8mb4_0900_ai_ci` on MySQL 8), and the mismatch surfaces months
> later as `Illegal mix of collations` on the first query that joins across it. Keep side-table
> value columns `NULL`-able: when several fields share the side table, every save writes the whole
> row, including columns whose fields don't apply to the record being saved — a `NOT NULL` column
> for one feature then breaks saving records of every other type.

See `technical/com_configbox_migrations.md` for the customization migration track, versioning, and the
`ConfigboxUpdateHelper` guards (`tableExists()`, `tableFieldExists()`). **Never `ALTER TABLE` by hand.**

### 4.2 The property injection

```php
// data/customization/model_property_customization/products.php
<?php
defined('CB_VALID_ENTRY') or die();

/**
 * Extra property definitions merged into ConfigboxModelProducts.
 * @return array[]
 * @see ConfigboxModelProducts::getPropertyDefinitions
 */
function customPropertyDefinitionsProducts() {

    $propDefs = array();

    $propDefs['care_instructions'] = array(
        'name'         => 'care_instructions',
        'type'         => 'string',
        'label'        => KText::_('Care instructions'),
        'tooltip'      => KText::_('Shown on the product page; how the customer should care for the item.'),
        'options'      => 'USE_TEXTAREA',   // string-type flag → multi-line widget
        'positionForm' => 250,              // where it appears in the edit form (sorted by this)
        'positionList' => 0,                // 0/omit → not shown as a list column (see note)
        'canSortBy'    => true,

        // The same field explained to somebody reading the API schema (see the note below):
        'apiTitle'       => 'Care instructions',
        'apiDescription' => 'Free-text care instructions shown on the product page. Plain text, may be empty.',

        // Store in our own side table instead of the core product table:
        'storeExternally'   => true,
        'foreignTableName'  => '#__configbox_product_care',
        'foreignTableAlias' => 'product_care',
        'foreignTableKey'   => 'product_id',
    );

    return $propDefs;
}
```

That single definition gives the product admin form a textarea, request parsing + storage into the side
table (the base class does the JOIN, read and upsert because `storeExternally` is set — see
`com_configbox_custom_properties.md` §6 for the customization walk-through, and
`../technical/com_configbox_property_types.md` §3 for the mechanism in full), and a sortable field. No
core model, controller, view or query was touched.

> **Two constraints on your side table.** `foreignTableKey` must carry a **UNIQUE** index — the write
> is an upsert relying on the duplicate-key collision, so without it every save inserts a second row
> instead of updating. And `foreignTableAlias` must be unique among the model's properties, or two
> external fields collide in the same query; several fields sharing one side table share one alias
> and one join, which is the intended way to add a group of columns at once.
>
> You do **not** need to clean up on delete: the base `delete()` removes the side-table row when the
> record goes. See `../technical/com_configbox_property_types.md` §3.

> **`storeExternally` needs a `column`-kind property type.** It moves *the property's column* to
> another table, so it does nothing for a `derived` type (`translatable`, `multiselect`,
> `taxclassrates`, `childentries`) or a `layout` one (`groupstart`/`groupend`, `note`) — those have
> no base-table column to move. The per-type applicability table is in the property-types overview.

> **Use `KText::_()` for labels** so the text is translatable through the i18n system (and overridable via
> `language_overrides/`). Real stock injections do exactly this.

> **Storing through a model: translations must be RE-SUPPLIED on every save.** A `translatable`
> property reads its per-language values from `<field>_<tag_key>` keys on the data object
> (`KenedoLanguageHelper::getTranslationKey('title', 'en-GB')` → `title_en_GB`), and a save whose
> data object is MISSING a language's key **deletes that language's string** — it does not keep
> the stored value. This bites import/seed scripts the moment they update a record from a
> `SELECT *` row (the row carries no translation keys, so the update silently blanks every
> title). Always attach every active language's text on every save, whether creating or
> updating. The demo seeders (`tools/setup-demo-*.php` in the cbx-joomla site repo) show the
> pattern.

### 4.3 Your field becomes an API field — `apiTitle`, `apiDescription`, regeneration

A definition merged into an entity-API model (products, questions, answers, …) is not only a form
widget: it becomes a **field of that entity's API**. It lands in the generated JSON schemas, the PHP
record stubs and the TypeScript types, and the entity endpoints read and write it like any stock
field. Three consequences:

- **Write the API pair.** `label`/`tooltip` are worded for an operator standing in front of the
  form; `apiTitle`/`apiDescription` are the same field explained to somebody reading a schema. Both
  fall back to the admin pair, so a field whose admin wording already reads correctly to a developer
  needs neither — but wording that assumes the form around it ("Choose yes to…") should get proper
  API prose. The house rule for new fields: **give every stored property the pair** rather than
  debating each one. Writing guidance (noun-phrase titles, what to state in the description):
  `../technical/com_configbox_property_definition_settings.md` §9.
- **Regenerate the artifacts.** After adding, removing or retyping a property, run
  `php cli/joomla.php configbox:generate-types` and commit the changed `generated/` files with the
  customization ( `--check` fails while they disagree, so a CI gate catches a forgotten run). The
  API reference/Postman export is `configbox:api:export`. Customization-added fields are marked
  (`x-configbox-origin`), and conditional ones carry their `appliesWhen` in the schema.
- **Your keys are your API.** Renaming a property `name` later breaks stored data *and* every API
  client that learned the field. Choose names as carefully as you would for a core field, and
  prefix them per feature (`care_`, `dim_`, `stab_`) so they cannot collide with future core fields.
- **The scope area doesn't change.** A field merged onto a stock model (products, questions, …) is
  gated by *that entity's* existing scope area (`catalog` for a product field) — adding a field never
  needs `KenedoModel::getApiArea()`. That method matters only for a **whole new** model your
  customization adds via `getEntityName()`; see
  `com_configbox_overriding_controllers_and_models.md` §4.

---

## 5. Tweaking an existing field (clone-then-change)

To change *how a stock field behaves* — move it in the listing, hide it from the list, drop its filter —
re-declare it by the **same `name`**, but **start from the original def** so you only change what you mean
to. This is the pattern the shipped customizations use:

```php
function customPropertyDefinitionsAdminproducts() {

    $propDefs = array();

    // Pull the model's own definitions so we can clone + tweak individual fields.
    $originalDefs = KenedoModel::getModel(ConfigboxModelProducts::class)->getPropertyDefinitions();

    // Move 'published' to the far right of the listing:
    if (isset($originalDefs['published'])) {
        $propDefs['published'] = $originalDefs['published'];
        $propDefs['published']['positionList'] = 40000;
    }

    // Hide 'baseprice' from the listing (keep it on the form):
    if (isset($originalDefs['baseprice'])) {
        $propDefs['baseprice'] = $originalDefs['baseprice'];
        unset($propDefs['baseprice']['positionList']);
    }

    return $propDefs;
}
```

Key points:

- **`getPropertyDefinitions()` returns the *raw definition arrays*** (not property objects), which is
  exactly what you want to clone and modify. Calling it on the model is safe here.
- **Guard with `isset()`** before cloning — field names change across versions; a missing key would
  otherwise throw and break the whole form.
- Because your returned def replaces the core one **wholesale** (§3), cloning-then-changing is the only
  safe way to "edit one key" — returning a partial def would drop everything you didn't copy.

> ⚠️ **Watch for re-entrancy.** Calling `getPropertyDefinitions()` inside the injection function is fine
> (it returns the *core* defs and does not re-invoke the merge), but do **not** call `getProperties()`
> from here — that triggers the merge again. Stick to `getPropertyDefinitions()` for cloning.

---

## 6. Deployment checklist

```
data/customization/
  model_property_customization/
    <modelname>.php                ← fn customPropertyDefinitions<Modelname>() returning defs keyed by name
  updates/
    <version>.php                  ← migration: create the column/table the new field maps
  properties/                      ← (only if the field needs a brand-new type — see custom-properties guide)
```

1. **Identify the model name** — the class after `ConfigboxModel`, lowercased, for the file; `ucfirst()` of
   that for the function. Get the casing exactly right or the file is silently skipped (§2).
2. **Deliver the schema first** via a customization migration, guarded with `ConfigboxUpdateHelper`
   (`tableExists`/`tableFieldExists`). The property maps an existing column; it never creates one.
3. **Write the injection function**, returning defs **keyed by `name`**. New `name` = add; existing `name`
   = override (clone-then-change, §5).
4. **Pick the type** — a built-in type, or your own from the custom-properties guide. Use `KText::_()` for
   labels.
5. **Use `positionForm`/`positionList`** to place the field (everything is re-sorted by `positionForm`).
6. **Write `apiTitle`/`apiDescription`** on every stored property (§4.3) and regenerate the artifacts:
   `php cli/joomla.php configbox:generate-types`, commit `generated/` together with the change.
7. **Verify manually** — load the admin form, save, reload the record, check the listing column/sort, and
   confirm storage landed where you intended. CBX has **zero automated tests**.

---

## 7. Conventions & gotchas

- **Casing of the function is fatal-by-silence.** `customPropertyDefinitions` + `ucfirst($fileBase)`.
  Wrong case → `function_exists()` fails → your file is ignored with no error. (A function that
  exists but *throws* is different: it is logged and skipped — check the CBX log.)
- **Keys are `name`s; merge replaces by name.** Same `name` as core = full replacement of that field's
  def. Clone-then-change to edit one key (§5).
- **Schema is the migration's job.** Add the column/table in `updates/`, idempotently. Don't `ALTER` by
  hand and don't expect the property to create storage.
- **Guard `isset()` when cloning core defs** — field names drift across versions.
- **`storeExternally` keeps you off the core table** — adding a side table avoids altering shipped tables
  and the merge conflicts that come with it. See `com_configbox_custom_properties.md` §6 for the
  foreign-table keys and `../technical/com_configbox_property_types.md` §3 for the full mechanism.
- **Pick the `type` deliberately.** The field you are adding almost certainly has a shipped type that
  fits — `translatable` if a customer reads it, `number` for anything numeric, `join` for a reference,
  `json` for a JSON column (never `string`, which HTML-escapes and corrupts it). The selection table is
  in `../technical/com_configbox_property_types.md` §4, with one article per type under
  `../technical/property-types/`.
- **One file per model.** All injected defs for a model go in its single
  `model_property_customization/<modelname>.php`; the loader includes exactly that one file.
- **Match the idioms.** Plain functions (not classes), no namespaces, `KText::_()` for strings — follow the
  shipped `model_property_customization/` files as your reference.

---

## See also

- `com_configbox_customization_overview.md` — the extension-point map (this is the **merge** row).
- `../technical/com_configbox_property_types.md` — **which `type` to give your field**: the settings
  every type accepts, the storage kinds, `storeExternally` (§3), the selection table (§4), and one
  article per type under `../technical/property-types/`.
- `../technical/com_configbox_property_definition_settings.md` — `dataType`, `nullable`, `unique`,
  `maxLength` for the column your migration delivers; §9 for writing `apiTitle`/`apiDescription`.
- `com_configbox_custom_properties.md` — build a new field *type* to use here; the full property-definition
  key reference (§5) and external storage (§6).
- `com_configbox_overriding_controllers_and_models.md` — when you need to change model *behavior*, not just
  add a field.
- `technical/com_configbox_kenedo_mvc.md` — how property definitions drive form/list/SQL/storage.
- `technical/com_configbox_migrations.md` — delivering the column/table the new field maps
  (customization track).
- `external/kenedo/classes/KenedoModel.php` — `getCustomPropertyDefinitions()` (`:802`),
  the merge in `getProperties()` (`:830`).
