# KenedoModel, in Detail

> A deep reference for KenedoModel — the data layer of CBX's bespoke "Kenedo" MVC framework — plus the minimum of KenedoController and KenedoProperty needed to…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/kenedo_model. Last updated 2026-08-25.

---
A deep reference for **`KenedoModel`** — the data layer of CBX's bespoke "Kenedo" MVC framework —
plus the minimum of `KenedoController` and `KenedoProperty` needed to see how the three connect. The goal
is that a new hire can **extend `KenedoModel` itself** (add a model, override a save/delete hook, wire a
parent→child tree) with confidence, and a third-party integrator can understand what runs *underneath* the
model-extension seams they use (`model_property_customization/`, `system_overrides/`, observers).

This is the sibling of `com_configbox_kenedo_controller.md` (the controller layer) and
`com_configbox_kenedo_view.md` (the view layer). It complements `com_configbox_kenedo_mvc.md` (the
whole-MVC overview) — where that doc summarizes the model in two sections (§3 the CRUD engine, §4 the
property model), this one is the full picture. All paths are relative to
`docroot/components/com_configbox/`; the framework lives under `external/kenedo/`. Line references track
current master — when one has drifted, search for the method name.

The base class is `external/kenedo/classes/KenedoModel.php` (2214 lines). Line references below are to it
unless another file is named.

---

## 0. Mental model

A `KenedoModel` is a **metadata-driven, single-table CRUD engine with an ORM-lite relation layer**. It is
the M in Kenedo's MVC, and it holds almost no per-entity logic of its own. A concrete model declares just
**three things** and inherits everything else:

1. **`getTableName()`** — the base table (`#__configbox_countries`).
2. **`getTableKey()`** — the primary key column (`id`).
3. **`getPropertyDefinitions()`** — an array of field descriptors (one per column/relation/widget).

From those three, the base class assembles **read, write, validate, list, filter, sort, copy, delete and
usage-reporting** generically — by **looping the model's properties** and asking each one for its
contribution at each phase. This is the single most important thing to internalize:

> **The model doesn't know its own columns. Its `KenedoProperty` objects do.** Every method that touches
> data (`getRecord`, `getRecords`, `store`, `validateData`, `delete`, `copy`, …) is a `foreach` over
> `getProperties()` that delegates the field-specific work to each property. The model orchestrates; the
> properties do the SQL, validation, storage and rendering. (`KenedoProperty` is the other half — see
> `com_configbox_kenedo_mvc.md` §4 and, per-property, `customization/com_configbox_custom_properties.md`.)

Key ideas:

- **Models are true singletons**, resolved by class name through `getModel()` — the registry
  `self::$instances` is actually populated and reused (`:228/:276/:280`), **like `KenedoController`** and
  **unlike `KenedoView`** (whose "singleton" is vestigial; see `com_configbox_kenedo_view.md` §1.1).
- **Class resolution is core-first.** A file in `data/customization/models/` loads only when core has **no**
  same-named model — so customization *adds* models, it doesn't shadow core ones (§2.2). (Note the
  contrast: property *types* resolve customization-first — §6.2.)
- **The controller is thin; the model does the work.** `store()`/`delete()`/`copy()`/`publish()` on
  `KenedoController` each parse the request and immediately delegate to the matching model method (§7).
- **Everything is generic and property-driven.** Adding a field is adding one property definition (plus a
  migration for the column) — form widget, list column, SQL, validation and storage all follow (§4, §8).
- **Soft-abstract hooks, not `parent::` chains.** `afterStore()`, `afterDelete()`, `getChildModel()`,
  `getRecordUsageInfo()` are empty/return-nothing by default and documented as "the base does nothing, so
  no `parent::` call needed" — override them cleanly (§9).

The rest of this document: resolution & naming (§1–§2), the property assembly that powers everything (§3),
reading (§4), writing — `store()` is the spine (§5), the property-object factory and the two resolution
directions (§6), how the controller & property layers connect (§7), the parent→child tree and `copy()`
(§8), and extending the model + the customization-layer techniques (§9).

---

## 1. What a concrete model looks like

A stock CRUD model is small. `models/countries.php` (trimmed) is the canonical shape:

```php
class ConfigboxModelCountries extends KenedoModel {

    function getTableName() { return '#__configbox_countries'; }
    function getTableKey()  { return 'id'; }

    function getPropertyDefinitions() {
        $propDefs = array();
        $propDefs['id']           = array('name'=>'id',   'type'=>'id',        'positionForm'=>100);
        $propDefs['general_start'] = array('name'=>'general_start','type'=>'groupstart', /* fieldset */ 'positionForm'=>200);
        $propDefs['country_name'] = array(
            'name'=>'country_name', 'type'=>'string', 'required'=>1,
            'positionList'=>1, 'addDropdownFilter'=>true, 'addSearchBox'=>true,
            'canSortBy'=>true, 'makeEditLink'=>true,
            'component'=>'com_configbox', 'controller'=>'admincountries',   // where the list-cell edit link points
            'positionForm'=>300,
        );
        $propDefs['zones'] = array('name'=>'zones', 'type'=>'multiselect', /* xref relation */ );
        return $propDefs;
    }
}
```

That is the entire model — no read/write/validate/list code. The `country_name` def alone yields: a form
string widget (at `positionForm` 300), a searchable/filterable/sortable **list column** (at `positionList`
1) whose cell links to the edit form, request parsing, required-validation, the SELECT, and storage into
the base table. The `zones` `multiselect` maps a many-to-many xref table. The `groupstart` is a
form-layout-only property (a visual fieldset, no column). See `com_configbox_kenedo_mvc.md` §4.1 for the
full definition-key vocabulary and §4.4 for the property-type catalog.

> A model can also **implement CRUD itself** instead of mapping a table — return an empty
> `getPropertyDefinitions()` (or a partial one) and override the read/write methods. The framework's
> config-style models (`config`, `shopdata`, `userfields`) do this; several models override
> `store()`/`getRecords()` directly (`userfields`, `answers`, `calcformulas`).
> The property-driven path below is the default, not the only, option.

---

## 2. Resolution & naming

### 2.1 The naming convention (CRUD quad)

The plural/singular convention pairs a model with a controller and two views. The model class is
`Configbox**Model**<Name>` ↔ file `models/<name>.php`; `<name>` is usually **plural** (the entity
collection). **Model files and classes carry no `admin` prefix** — that prefix stays on the
controllers and views, where it drives the admin permission gate. Worked example (Countries):

| File | Class | Role |
|---|---|---|
| `models/countries.php` | `ConfigboxModelCountries` | the model (this doc) |
| `controllers/admincountries.php` | `ConfigboxControllerAdmincountries` | the controller (drives the tasks) |
| `views/admincountries/view.html.php` | `ConfigboxViewAdmincountries` | the **list** view |
| `views/admincountry/view.html.php` | `ConfigboxViewAdmincountry` | the **form** view |

Both views' `getDefaultModel()` and the controller's `getDefaultModel()` return the **same** model —
`KenedoModel::getModel('ConfigboxModelCountries')` — so all three agree on the data source without
passing it around (that's what the true-singleton registry buys, §2.2). `getModelName()` (`:1463`) is the
inverse (`ConfigboxModelCountries` → `countries`), used to build the base-table alias, the
`onAfter*Record` event names, and log messages.

### 2.2 Loading the class — `getModel()` (`:158`) — a **true** singleton

```php
if (!isset(self::$instances[$className])) {
    $component     = 'com_' . strtolower(substr($className, 0, strpos($className,'Model')));  // :231
    $modelFileName = strtolower(substr($className, strpos($className,'Model') + 5)) . '.php'; // :232
    $regularPath = <component>/models/<file>;                          // core       :240
    $customPath  = data/customization/models/<file>;                  // customization :241
    // core-first: use $regularPath if it exists, else $customPath     // :244-252
    require_once($path);
    self::$instances[$className] = new $className($component);         // :276
}
return self::$instances[$className];                                   // :280
```

Two things to internalize:

- **Core-first resolution.** A file in `data/customization/models/` only loads when core has **no**
  same-named model — so the customization folder adds *new* models; it does **not** shadow a core one. To
  change an existing core model's behavior, use `system_overrides/`. (Same rule as controllers and view
  *classes*; contrast property *types*, which are customization-first — §6.2. Full precedence table:
  `com_configbox_kenedo_mvc.md` §7 and `customization/com_configbox_overriding_controllers_and_models.md`.)
- **Real singleton.** Unlike `KenedoView::getView()` (fresh instance every call), the model registry
  `self::$instances` is populated and reused — **one instance per class name per request**. This matters
  because the model **memoizes** loaded records and property objects on the instance (§3, §4.3): two callers
  asking for `ConfigboxModelProducts` share the same object *and its caches* within a request.

`getModel()` is also the chokepoint where **every legacy model name still resolves**, in three
mechanisms, all logged so the callers are findable:

- **The `Admin*` shim** (`:210-226`): the `admin` prefix was removed from all model class names, and a
  regex rewrite serves `ConfigboxModelAdmin<X>` as `ConfigboxModel<X>` with a deprecation log (once
  per name). Customization code and frozen migration scripts that ask for the old names by string keep
  working; new code uses the new names.
- **The CB4 vocabulary aliases** (`:186-202`): the option/answer split collapsed into one answers
  model — `Adminoptions`, `Adminoptionassignments` and `Adminxrefelementoptions` all resolve to
  `ConfigboxModelAnswers` — and the element→question rename maps `Adminelements` to
  `ConfigboxModelQuestions`.
- **The `Cbcheckout→Configbox` fallback** (`MERGELEGACY`, `:235-237`, `:266-273`): if the component
  resolves to `com_cbcheckout` it's rewritten to `com_configbox`, and if `ConfigboxModel<X>` isn't
  defined but `CbcheckoutModel<X>` is, the old class is used with a legacy-call warning. Three
  **renamed models** are also redirected here (`:171-184`):
  `CbcheckoutModelOrder→ConfigboxModelOrderrecord`, `ConfigboxModelGrandorder→ConfigboxModelCart`,
  `ConfigboxModelOrder→ConfigboxModelCartposition`.

If you're reading old integration code that references any of those names, this is why it still works.

---

## 3. The property assembly — `getProperties()` (the engine's fuel, `:1243`)

Every data method calls `getProperties()` first. It builds — and memoizes — the model's list of
`KenedoProperty` objects:

```php
if (empty($this->memoGetProperties)) {
    $propDefs       = $this->getPropertyDefinitions();          // the model's own defs        :1246
    $customPropDefs = $this->getCustomPropertyDefinitions();    // merged-in customization defs :1248
    $propDefs       = array_merge($propDefs, $customPropDefs);  // custom wins on name collision :1250
    uasort($propDefs, array('KenedoModel','sortProperties'));   // sort by positionForm         :1579
    foreach ($propDefs as $propDef) {
        // NO PLATFORM FILTER HERE, DELIBERATELY — see 3. below                                 :1584
        $this->memoGetProperties[$propDef['name']] = $this->getPropertyObject($propDef['type'], $propDef);
    }
}
return $this->memoGetProperties;
```

Five things happen here, each load-bearing:

1. **Custom defs merge in.** `getCustomPropertyDefinitions()` (`:1169`) pulls extra/overriding definitions
   from `data/customization/model_property_customization/<modelname>.php` (a plain function
   `customPropertyDefinitions<Modelname>()` returning defs keyed by `name`). This is the **`model_property_customization/`**
   extension point — the additive, upgrade-safe way to add a field to a stock model without editing it. It's
   an `array_merge` keyed by `name`, so **a `name` core already has is *replaced* wholesale; a new `name` is
   *added*** (§9.3, and the how-to in `customization/com_configbox_extending_stock_models.md`). The load is
   **defensive** (`:1194-1201`): a customization function that errors — typically because it references
   something a core refactor removed — is logged and skipped rather than white-screening the whole admin.
   Renamed/merged models can also name **legacy file bases** via `getLegacyCustomizationAliases()` (`:1153`)
   so old per-site customization files keep loading.
2. **Everything is sorted by `positionForm`** (`sortProperties`, `:1338`). This single order drives the form
   field order *and* the loop order for SQL/validation/storage. (The **list** column order is a separate
   `positionList` sort in `getPropertiesForListing()`, `:1414`.)
3. **Nothing is filtered by platform** (`:1584`), and that is deliberate. **Every property exists on
   every host** — stored, readable, writable, one schema, one set of migrations. A definition still says
   where it *applies* (`'platforms' => array('joomla', 'wordpress')`; absent meaning everywhere), but that
   governs **presentation**: `KenedoProperty::isVisible()` is false where it does not apply and
   `isRequired()` returns false with it, so a hidden field can never refuse a save nobody could satisfy.
   Where a host also needs a **different starting value**, the definition says
   `'platformDefaults' => array('magento2' => 0)` — resolved by `getPropertyDefinition('default')`, so
   every caller that builds a blank record gets it; the column and its DB-level default are identical
   everywhere and existing rows are never touched.

   This **used to be a filter**, and dropping it was the point: while the runtime shape differed per host,
   the entity API's writable set, the generated schemas and `cbx_describe_entity` disagreed with each
   other — describe advertised `baseprice` on Magento and the create then refused it. Type generation
   reads definitions rather than this method (`getPropertiesForAllPlatforms()`, `:1617`), so committed
   artifacts describe every platform at once and annotate the exceptions with `x-configbox-platforms`
   and `x-configbox-platform-defaults`.
4. **Each def becomes a typed `KenedoProperty<Type>` object** via `getPropertyObject()` (`:1364`, §6). The
   `type` string selects the class; the def array configures it.
5. **The result is memoized** on the instance (`memoGetProperties`). Because models are singletons (§2.2),
   the property objects are built once per model per request. `getProperties()` is safe to call repeatedly.

The returned array is **keyed by property `name`**, so `$props['country_name']` is that field's object.
`getPropertiesForListing()` (`:1414`) filters to properties flagged into the list and re-keys them by
`getListingPosition()`; the view uses it for grids (`com_configbox_kenedo_view.md` §2.2).

Two deliberate escapes from the runtime set exist, both unmemoized so they can never hand application
code the wrong shape:

- **`getPropertiesForAllPlatforms()`** (`:1617`) — what type generation reads. It returns the **same
  set** as `getProperties()` now that there is no platform filter (see 3. above); it is kept because
  it says what the CALLER means — "every platform's fields, whatever host I am on" — and because
  that guarantee is the one the committed artifacts depend on.
- **`getBaseProperties()`** (`:1316`) — the **stock** definitions only, ignoring whatever
  `model_property_customization` files the install has. Type generation uses it to tell stock fields
  from customization-added ones; origin is about who defines a field, not where it runs.

> **Delegation, both directions.** `getPropertyObject()` constructs each property as
> `new KenedoProperty<Type>($propertyDefinition, $this)` (`:1407`) — passing **the model itself** as the
> second arg. So every property holds a back-reference to its model (`KenedoProperty::$model`,
> `KenedoProperty.php:72`). The model loops properties; a property can call back into the model. That
> two-way link is what makes the mini-ORM work (a `join` property, for instance, resolves *another* model
> via `KenedoModel::getModel()` to build its JOINs — §4.2).

---

## 4. Reading — `getRecord()` and `getRecords()`

### 4.1 `getRecord($id, $languageTag)` (`:1616`) — one record, assembled from the properties

```php
$props = $this->getProperties();
$selects = $joins = array();
foreach ($props as $prop) {
    $selects = array_merge($selects, $prop->getSelectsForGetRecord());   // each field's SELECT fragment(s)
    $joins   = array_merge($joins,   $prop->getJoinsForGetRecord());     // each field's JOIN(s)
}
// SELECT <selects> FROM <table> AS <ModelName> <joins> WHERE <key> = intval($id)
$record = $db->loadObject();
foreach ($props as $prop) { $prop->appendDataForGetRecord($record); }    // post-load enrichment
```

The record's shape is **the union of what every property contributes** — a plain `string` adds one column;
a `translatable` adds a `LEFT JOIN` on `#__configbox_strings` for the current language; a `join` adds the
related table's columns under an aliased name; a `file` adds nothing to SQL but enriches the record in
`appendDataForGetRecord()`. The language tag defaults to the current system language, is validated against
the active set (throws otherwise, `:1624-1628`), and is stashed on `$this->languageTag` so the translatable
properties know which language column to read.

> **No record caching (since 2026-07-26).** `getRecord()` and `getRecords()` used to memoize their
> results on the model instance. They no longer do: **every call is a query.** The memo was measured
> at a **0% hit rate** across a mixed admin/MCP workload, and removing it cost ~25 extra queries out
> of ~2200 (~2.7% per request). Both checks used `empty()` rather than `isset()`, so missing records
> and empty result sets were re-queried anyway — only repeat reads of populated records were ever
> cached, and almost nothing did that twice.
>
> It also cost correctness. Models are process-wide singletons, so anything that outlived one logical
> request served stale rows: an external `UPDATE` between two identical reads in one process was
> invisible to the second. And because the memo handed every caller **the same object**, code that
> reshaped a record for output silently rewrote the cached row for everyone after it.
>
> `forgetRecord($id)`, `forgetRecords()` and `forgetAllRecords()` are kept as **deprecated no-ops**:
> calling them was the documented way to force a re-read, so existing customization code stays
> correct — the reads it was protecting are simply always fresh now.
>
> **Property and metadata memoization is untouched** (`memoGetProperties`, `memoPropertyClassNames`,
> §3). That is a different thing: it keeps the property-definition walk from being repeated per call,
> and unlike record data it cannot go stale within a request.
>
> Translations are fetched once per read rather than per field — `KenedoPropertyTranslatable::preload()`
> runs before the per-property pass. See [`property-types/translatable.md`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/translatable).

Returns `null` when no row matches (`:1656`) — callers must handle a missing record.

#### What a record object is — hydration and casting

A record is not a bare `stdClass` any more, though it still behaves like one:

- **Hydration.** `getRecordClass()` (`:1556`) resolves the model's **generated record class** from
  `getEntityName()` — `'sales-order'` → `ConfigboxRecordSalesOrder` — falling back to `'stdClass'`
  when the model has no entity name or the artifact does not exist. `hydrateRecord()` (`:1591`) copies
  the fetched row's properties into an instance of that class. The generated classes **extend
  `stdClass`**, hold shape and never behaviour, so dynamic properties stay allowed and every loose
  consumer habit — `$record instanceof stdClass` included — keeps working.
- **Casting.** mysqli hands every value over as a string; `castRecordValues()` (`:1120`) runs at the
  end of `getRecord()` and `getRecords()` and casts the model's numeric columns to real PHP ints and
  floats, per the `getCastMap()` metadata memo (`:1080`, built from the **declared** `dataType`s, so
  the definitions stay the one source). NULL stays NULL. **Booleans deliberately stay `'1'`/`'0'`
  strings** — the codebase compares them loosely everywhere, and a real `bool` would break every
  `=== '1'`.

### 4.2 `getRecords($filters, $pagination, $sortSpecs, $languageTag, $countOnly)` (`:1694`) — lists

Same property-driven SELECT/JOIN assembly as `getRecord`, plus four list concerns folded in by looping the
properties and the args:

- **Filters** — each property's `getWheres($filters)` contributes `WHERE` fragments (`:1723`). Filter
  *names* come from `getFilterNames()` (`:1877`, which asks each property `getFilterName()`).
- **Sorting** — `$sortSpecs` (an array of `{propertyName, direction}`) is resolved to `ORDER BY` column
  references (`:1731-1788`). A **`join` property sorts by the related model's `propNameDisplay` column** —
  the code resolves the parent model via `KenedoModel::getModel()` and uses *its* property's table
  alias/column (`:1770-1782`). This is a concrete case of the model↔model delegation from §3.
- **Grouping** — each property's `getGroupingColumnsForGetRecord()` (`:1722`) → `GROUP BY`.
- **Pagination** — `LIMIT start, limit` (`:1830`), or **count-only** mode that drops `ORDER BY`/`LIMIT` and
  returns the row count (`:1792/:1823`).

Records are **not cached** — see the note in §4.1. Every call runs the query. The result is still
returned **by reference**, so a caller that reshapes a record for output should `clone` it first
rather than rewriting the array its caller is holding; the `copy()` path does exactly this (§8).

### 4.3 The SQL-building style (read before touching queries)

Kenedo traditionally builds SQL as **manually concatenated `mysqli` strings** — no query builder. Since
2026-08 `KenedoDatabase` also offers an opt-in prepared statement layer — `setPreparedQuery($sql, $params)`
with `:named`/`?` placeholders, IN-list expansion and type-aware binding; every `load*()` method works
unchanged on top, and it is the preferred form for **new** queries. The concatenation discipline below
still governs the existing ones you must match:

- **IDs are `intval()`-cast** at interpolation (`getRecord` `:1651`, `delete` `:2026`, `storeOrdering`
  `:2135`, `publish` `:1947`).
- **Identifiers** (table/column names) go through `$db->getQuoted(...)` — which **quotes but does not
  escape** — and **values** through `$db->getEscaped(...)`. Sort directions are escaped (`:1785`); sort
  *column references* coming from a `propertyName` with a `.` in it are split, escaped and quoted
  (`:1758-1759`) — but a metadata-derived identifier is otherwise trusted.
- **`getRecordUsage()`** (`:2157`) interpolates `titleField`/`fkField`/`filterField` **directly from the
  model's own `getRecordUsageInfo()` metadata** — safe because that metadata is
  developer-authored, not user input. Don't feed request data into those keys.

The rule of thumb from `CLAUDE.md`: **`getQuoted()` quotes identifiers (it does not escape them);
`getEscaped()` escapes values (inside quotes you write yourself)**; follow the escaping pattern of the
neighboring code, and be careful with anything derived from user input. The full database-layer
reference — `setQuery`, `setPreparedQuery`, the `load*` family, transactions — is
**`com_configbox_kenedo_database.md`**; the framework-wide caveat is in `com_configbox_kenedo_mvc.md` §8.

### 4.4 Empty-record helpers — `initData()` (`:1476`)

For a **new** (insert) form the view calls `initData()`, which builds a blank record honoring:
- `default` values from each property def,
- `prefill_<propertyName>` **request** params (so a "New child of parent X" link can pre-fill the parent
  FK),
- an experimental step that, for a pre-filled `join` property, loads the parent record's columns into the
  blank record so the form can show the parent's data (`:1503-1538`, wrapped in try/catch — failures are
  logged as warnings, not fatal).

`getRecord()` vs `initData()` is the load-vs-blank split the view branches on (`com_configbox_kenedo_view.md`
§2.3).

---

## 5. Writing — `store()` (the spine of all saving, `:476`)

`store($data)` persists one record and all its property side-data in a **single DB transaction**:

```php
$db->startTransaction();
$isInsert = $this->isInsert($data);                                   // empty PK ⇒ insert   :485

// 1. Build the base-table row: ask each property which keys it owns in the base table
$baseData = new stdClass();
foreach ($this->getProperties() as $property) {
    foreach ($property->getDataKeysForBaseTable($data) as $key) {     // [] if stored elsewhere :491
        $value = $data->{$key};                                       // (throws if key missing) :494-496
        // NULL on a NOT-nullable column → declared default, else type-appropriate empty  :506-517
        $baseData->{$key} = $value;
    }
}
// Platform-filtered properties still own columns here: fill their DECLARED defaults on insert :534-545
$db->insertObject($this->getTableName(), $baseData, $this->getTableKey());  // insert or update on PK :547
$id = $baseData->{$this->getTableKey()};
$data->{$this->getTableKey()} = $id;                                  // back-fill the new id  :550

// 2. Let each property persist its OWN side data (external tables, translations, child rows)
foreach ($this->getProperties() as $property) {
    if ($property->store($data) === false) {                          // a false ⇒ throw       :558-562
        throw KenedoSystemException::logged('...property returned false and raised no exception');
    }                                    // NOTE: no rollback here - that is the catch block's
}                                        // job, and doing both pops two savepoints for one level

ConfigboxAssignmentsHelper::forgetForTable($this->getTableName());   // drop stale lookups     :571
KenedoPropertyTranslatable::forget();                                //                        :572
$this->afterStore($id, $isInsert);                                   // subclass hook          :574
$db->commitTransaction();
KenedoObserver::triggerEvent('onAfterStoreRecord', array($this->getModelName(), $data));  // :578
return true;
```

The two-phase structure is the point:

1. **Base-table phase.** Properties that live in the model's own table return their column name(s) from
   `getDataKeysForBaseTable()`; the model collects those into `$baseData` and does one
   `insertObject()` (which INSERTs when the PK is empty, UPDATEs otherwise). Properties stored elsewhere
   return `[]` here. Two coercions keep the row valid under the connection's **strict `sql_mode`**
   (see `KenedoDatabase::connect()` — the historical `sql_mode = ''` silently stored implicit defaults;
   strict mode would refuse instead):
   - **NULL on a NOT-nullable column** (`:506-517`) — a field that was not posted arrives as NULL
     (`getDataFromRequest()`'s convention for "absent"); it becomes the property's **declared
     `default`**, else a type-appropriate empty (`0` for numeric column types, `''` otherwise).
     Nullable columns keep their NULL: absent means NULL there.
   - **Platform-filtered defaults** (`:534-545`) — a property limited to *other* platforms drops out
     of `getProperties()` (§3) but still owns a real column here, typically NOT NULL without a DB
     default. On an INSERT the loop over `getPropertiesForAllPlatforms()` fills each such column-kind
     property with its **declared** default (or `''`), so the INSERT is not refused with error 1364.
     UPDATEs need nothing: an omitted column is left untouched.
2. **Side-data phase.** Every property's own `store($data)` runs — a `translatable` upserts into
   `#__configbox_strings`, a property with `storeExternally` upserts into its foreign table
   (`INSERT … ON DUPLICATE KEY UPDATE`), a `childentries` writes child rows, a `multiselect` rewrites its
   xref rows. A property that cannot store its data **throws**; a `false` return is turned into a
   `KenedoSystemException` here. Either way the catch block rolls the whole transaction back.

Around it: record data is never cached (§4.1), so there is nothing to drop for *this* model — what the
write does invalidate is the **assignment lookups** (`ConfigboxAssignmentsHelper::forgetForTable()`,
which memoize id-to-id relations for the request) and the **translation preload cache**
(`KenedoPropertyTranslatable::forget()`), or a question just moved to another page would keep
answering with where it used to belong for the rest of the request. **`afterStore($id, $wasInsert)`**
(`:630`) is the empty soft-abstract hook for post-save side effects (§9.1); on success the
**`onAfterStoreRecord`** observer fires (the additive extension point — `customization/com_configbox_events_and_observers.md`).
Any exception rolls the transaction back and is **rethrown**, never swallowed. A
`KenedoValidationException` travels on unchanged (the caller can fix it — 422); a
`KenedoSystemException` likewise, already logged with its detail; anything else is wrapped in
`KenedoSystemException::fromException()`, which logs message, file, line and trace and hands back an
exception carrying only the log identifier. `store()` never returns `false` (`:583-614`).

Rollback is once per level and no more. `KenedoDatabase` stacks nested transactions as savepoints and
**pops one per `rollbackTransaction()`**, so a level that rolled back before throwing would unwind two
levels for one, and the outermost rollback would then throw "no transaction was started" — replacing
the real reason with a spurious one.

The **request → data → validate → store** pipeline the controller drives (§7) is likewise all property
loops:
- **`getDataFromRequest()`** (`:329`) → each property's `getDataFromRequest(&$data)` pulls its field(s) from
  the request into a normalized `stdClass`.
- **`prepareForStorage($data)`** (`:353`) → each property's `prepareForStorage()` auto-fills/normalizes
  (a `false` becomes a thrown refusal).
- **`validateData($data, $context)`** (`:381`) → for each property that **`applies($data)`** (`:389`, honoring
  conditional-applicability), call `check($data)`; refusals are absorbed into one
  `KenedoValidationException` that keeps collecting across properties, so the user sees *all* errors at
  once.
- **`isInsert($data)`** (`:429`) → `empty($data->{primaryKey})`.

So "save a record" is really "loop the properties five times — read, prepare, validate, base-write,
side-write." No column names appear in the model.

---

## 6. The property-object factory — `getPropertyObject()` (`:1364`) and the two resolution directions

`getPropertyObject($type, $def)` turns a `type` string into a configured property object:

```php
$candidates = array(                                        // tried in order            :1374-1379
    getDirCustomization().'/properties/'.strtolower($type).'.php',      // custom, lowercase file
    …/external/kenedo/properties/'.strtolower($type).'.php',            // core,   lowercase file
    getDirCustomization().'/properties/'.$type.'.php',                  // custom, exact-cased file
    …/external/kenedo/properties/'.$type.'.php',                        // core,   exact-cased file
);
$className = 'KenedoProperty'.ucfirst($type);               // type casing PRESERVED     :1397
require_once($classFile);
return new $className($def, $this);                                                      // :1407
```

### 6.1 `type` → class → file

`type` `'string'` → class `KenedoPropertyString` → file `properties/string.php`. **The type's casing is
preserved in the class name**: a CamelCase type like `'taxBehavior'` resolves to
`KenedoPropertyTaxBehavior` while still mapping to a lowercase file (`taxbehavior.php`); the exact-cased
file names are a fallback for custom properties shipping a CamelCase file. (PHP class names are
case-insensitive, so all-lowercase types resolve to the same class as before.) The class-name→file map is
memoized statically (`self::$memoPropertyClassNames`, `:1366/:1401`) since types repeat across models. An
unknown type throws (`:1389-1392`).

### 6.2 The resolution direction is the *opposite* of model classes

This is the subtle bit worth flagging: **property *types* resolve customization-first** — the custom
folder is checked **before** the system folder at each casing (`:1374-1379`) — so a
`data/customization/properties/<type>.php` **shadows**
the core property of the same type. That's the inverse of model/controller/view *classes*, which are
**core-first / new-only** (§2.2). The reason: property types are meant to be *replaceable widgets*, whereas
model classes are meant to be *extended additively* (via `model_property_customization/`, §9.3) or replaced
via `system_overrides/`. So:

| Resolution | Direction | To change an existing core one |
|---|---|---|
| **Model class** (`getModel`, `:244-252`) | **core-first / new-only** | `system_overrides/` |
| **Property type** (`getPropertyObject`, `:1374-1379`) | **customization-first / shadows** | drop a same-type file in `data/customization/properties/` |

Building a **new** property type is `customization/com_configbox_custom_properties.md`. The **type
catalogue** — every shipped type, its own settings, its storage kind and its column — is
[`com_configbox_property_types.md`](https://docs.configbox.at/docs/4.0-preview/technical/property_types), one article per type under
[`property-types/`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/); the lifecycle-hook table is `com_configbox_kenedo_mvc.md` §4.2.

---

## 7. How the Controller and Property layers connect

`KenedoModel` is the M; here's just enough of C and P to see the wiring. Full detail:
`com_configbox_kenedo_controller.md` and `com_configbox_kenedo_mvc.md` §4.

### 7.1 Controller → Model (the write path)

The controller's write tasks parse the request, delegate to the model, purge cache, and answer with JSON —
they **never touch SQL**. The mapping (all on `KenedoController`):

| Controller task | Model call(s) |
|---|---|
| `store()` / `apply()` / `storeAndNew()` (`KenedoController.php:371`) | `getDataFromRequest`, then the shared pipeline (`ConfigboxEntityApiHelper::runStorePipeline()`): `prepareForStorage` → `isInsert` → `validateData` → `store`; a `KenedoValidationException` is answered 422 with per-field issues, a `KenedoSystemException` 500. Then reads back `getRecord($id)` for the JSON |
| `getRecord()` / `getRecords()` (`:715/:764`) | `$model->getRecord($id, $lang)` / `$model->getRecords($filters, $pagination)` (+ count-only call), projected via `ConfigboxApiRecord` |
| `delete()` / `ajaxDelete()` (`:836/:809`) | `$model->delete($ids)` (which runs `canDelete()` first), via `ConfigboxEntityApiHelper::deleteManyForModel()` |
| `copy()` (`:898`) | `$model->copy($data)` (recursive deep-clone; §8) |
| `publish()` / `unpublish()` + ajax variants (`:1076/:1125`) | `$model->publish($ids, $publish)` |
| `storeOrdering()` (`:1136`) | `$model->storeOrdering($updates)` |

`store()` is the fixed sequence walked in §5; the controller's step-by-step is
`com_configbox_kenedo_controller.md` §5. The controller also owns `afterStore($success)` as a *controller*-side
hook — distinct from the *model*-side `afterStore($id, $wasInsert)` (§9.1). Both exist; don't confuse them.

### 7.2 View → Model (the read path)

The view pulls everything it renders from `getDefaultModel()` (`com_configbox_kenedo_view.md` §7.2):
- `getProperties()` / `getPropertiesForListing()` → the form/list field set,
- `getRecord($id)` / `initData()` → the form's record,
- `getRecords($filters, $pagination, $sortSpecs)` → the list's rows,
- `getRecordUsage($id)` → the delete-safety "where is this used" report on the form,
- `getFilterNames()` → the list's available filters,
- `getListingTasks()` / `getDetailsTasks()` → the toolbar button definitions (§9.4).

### 7.3 Model → Property (the delegation core)

Every model data method is a loop over `getProperties()` that calls the property's phase hook. The full hook
table is `com_configbox_kenedo_mvc.md` §4.2; the model-side callers, in one place:

| Model method | Property hook it loops |
|---|---|
| `getDataFromRequest` | `getDataFromRequest(&$data)` |
| `prepareForStorage` | `prepareForStorage(&$data)` |
| `validateData` | `applies($data)` then `check($data)` |
| `store` (base) | `getDataKeysForBaseTable($data)` |
| `store` (side) | `store(&$data)` |
| `getRecord`/`getRecords` | `getSelectsForGetRecord`, `getJoinsForGetRecord`, `getGroupingColumnsForGetRecord`, `getWheres`, then `appendDataForGetRecord(&$record)` |
| `getFilterNames` | `getFilterName()` |
| `delete` | `canDelete($id)`, then `delete($id, $table)` |
| `copy` | `copy($data, $newId, $oldId)` |
| `publish` | finds the single `type=published` property, updates its column |

And the back-reference makes it bidirectional: a property holds `$this->model` (`KenedoProperty.php:25`) and
can call `KenedoModel::getModel(<otherModel>)` to reach *another* model — which is how `join`/`childentries`
properties resolve their related entities (§4.2, §8).

---

## 8. The parent→child tree, `copy()`, and usage reporting

Beyond single-record CRUD, `KenedoModel` models an **entity tree** and does recursive operations over it.

### 8.1 Declaring a child model

A model with one-to-many children declares two methods (`getChildModel()` `:307`, `getChildModelForeignKey()`
`:315`) — e.g. products → pages → questions form a three-level tree (`models/products.php:32` declares
child `ConfigboxModelPages`, `models/pages.php:26` declares child `ConfigboxModelQuestions`, and
`models/questions.php:40` ends the tree; answers hang off questions via a `childentries` property, not
the child-model chain):

```php
function getChildModel()           { return 'ConfigboxModelPages'; }
function getChildModelForeignKey() { return 'product_id'; }   // the child prop holding the parent FK
```

These are the only declarations needed; the base class uses the child model's own properties (and the
foreign-key property's **filter name**) to find, copy and export child records generically.

### 8.2 `copy()` (`:649`) — recursive, transactional deep-clone

`copy($data)` clones a record and, recursively, its whole subtree, each level in its own transaction:

1. Clone `$data`, **null the primary key**, build the base row (same `getDataKeysForBaseTable()` loop as
   `store`, but asking each property `getValueForCopy()` — a unique column has to become something else
   on a copy, and only the property knows how), `insertObject()` → a **new id**.
2. Record the mapping in the static `self::$copyIds[$modelName][$oldId] = $newId` (`:706`) — the cross-record
   id map two-pass rule-remapping needs (below).
3. Loop properties, calling each `$property->copy($data, $newId, $oldId)` (side-data clone, `:723`).
4. Find child records via `$childModel->getRecords([<fkFilterName> => $oldId])`, re-point each child's FK to
   `$newId`, and **recurse into `$childModel->copy()`** (`:737-805`). A child failure **throws**; each
   level rolls back its own savepoint, adds a context line ("copy of <model> ID <n>") and rethrows, so
   the reason arrives at the controller with its identity intact rather than the Nth flattening of it.
5. Fire `onAfterCopyRecord`, commit, return the new id.

Because rules and calculations reference **other records by id**, a straight clone would leave them pointing
at the originals. `copyRulesAndCalculations($recordId, $copyIds)` (`:876`) is the **second pass**: after the
whole subtree is cloned (so `self::$copyIds` holds every old→new mapping), it walks the tree again and
rewrites each `rule`/`calculation`/`calculationoverride`/`childentries` property's stored references through
the id map. The full two-pass walkthrough is `com_configbox_mvc_tasks.md` (the `copy()` deep dive).

### 8.3 Delete & delete-safety

`delete($ids)` (`:1970`) normalizes `$ids`, runs **`canDelete($id)`** on each first — every refusal is a
**thrown `KenedoValidationException`**, and the loop **absorbs** them across ids (`:1992-2010`) so the
operator learns about *all* blocking reasons at once — then, per id, loops properties' `delete()` and
issues the base `DELETE`, firing `afterDelete($id)` + `onAfterDeleteRecord` per row, all in one
transaction. A foreign-key violation (SQL error `1451`) is translated to a friendly "linked with other
records" refusal (`:2061`).
`canDelete()` (`:2093`) throws when any property says so **or** when `getRecordUsage()` finds inbound
references.

`getRecordUsage($id)` (`:2157`) reads the model's declarative **`getRecordUsageInfo()`** (`:134`) — a nested
`component → model → [{titleField, fkField, controller, name, …}]` map — and runs a query per entry to list
the records that reference this one, each with a deep-link to its edit form. This powers the "This record is
used by …" panel on edit forms and the delete guard. `calculations`/`taxclasses`/`customergroups`
declare `getRecordUsageInfo()`; look there for the shape.

`storeOrdering($ordering)` (`:2126`) persists a `{id: position}` map into each row's `ordering` column
(one `UPDATE` per id, `intval`-guarded).

---

## 9. Extending KenedoModel — and the customization-layer techniques

There are **four** ways to influence a model, in rough order of preference. Pick the least invasive that
does the job.

### 9.1 Override a soft-abstract hook (add behavior to *your own* new model, or via a system override)

The base class provides empty/return-nothing hooks documented as **"the base does no processing, so you
don't need `parent::`"**. Override these to add behavior without reimplementing the CRUD methods:

- **`afterStore($id, $wasInsert)`** (`:630`) — runs inside the store transaction after a successful save.
  The canonical place for post-save side effects (recompute, sync, stamp a derived column). **Throw** to
  abort the save; the transaction it runs in rolls back with it. Note that its RETURN VALUE is ignored by
  `store()` and always has been, so a failure reported that way is silently lost — the `calculations`
  model relies on exactly that and logs instead. *(Distinct from the controller's `afterStore($success)` —
  §7.1.)*
- **`afterDelete($id)`** (`:2077`) — post-delete side effects (runs per deleted id, in the transaction).
- **`getChildModel()` / `getChildModelForeignKey()`** (`:307/:315`) — wire a parent→child tree (§8.1).
- **`getRecordUsageInfo()`** (`:134`) — declare inbound references for delete-safety + the usage panel (§8.3).
- **`getListingTasks()` / `getDetailsTasks()`** (`:1434/:1449`) — the list/form toolbar buttons (§9.4).

And the hooks that surface a model on the **entity API** (the shared HTTP/MCP layer — full picture in
`com_configbox_entity_api.md`):

- **`getEntityName()`** (`:83`) — the entity's stable, singular kebab-case public name (`'tax-class'`);
  `''` (the default) opts the model out of type generation and the entity API entirely.
- **`getApiOperations()`** (`:98`) — which of `list`/`read`/`create`/`update`/`delete` the entity
  supports; singleton entities (settings, store record) narrow it.
- **`getAugmentedRecordKeys()`** (`:110`) — record keys a `getRecord()` *override* adds imperatively,
  declared so type generation can describe what the select list cannot show it; almost always empty.
- **`getExportData()` / `getDataFromTransferRecord()`** (`:954`/`:940`) — the product-transfer pair:
  export a record plus its child tree and property files, and rebuild a `$data` object from such a
  transfer record on import.

Real examples: `calculations` and `calcmatrices` override `afterStore`.
These hooks live on *your* model class, so for a **new** model you just override them. To change a **core**
model's hook you need a `system_overrides/` class replacement (§9.2) — you can't shadow the core model file.

### 9.2 Replace a core model's behavior — `system_overrides/`

Because `getModel()` is **core-first / new-only** (§2.2), you **cannot** change a core model by dropping a
same-named file in `data/customization/models/` (that only registers *new* models). To change an *existing*
core model's methods — override `store()`, `getRecords()`, `afterStore()`, add validation beyond a property
— replace the class via `system_overrides/`. Full mechanics and the re-diff-after-update cost:
`customization/com_configbox_system_overrides_and_boot_hooks.md` and
`customization/com_configbox_overriding_controllers_and_models.md`. Prefer §9.1/§9.3/§9.5 first; reach for
this only when the change is genuinely *behavior* on a *core* class.

### 9.3 Add or tweak a field — `model_property_customization/` (the merge, §3)

For a **data-shape** change (add a field, move a list column, hide a filter) on a stock model, contribute
property defs via `getCustomPropertyDefinitions()` (`:1169`): a plain function
`customPropertyDefinitions<Modelname>()` in `data/customization/model_property_customization/<modelname>.php`
returning defs **keyed by `name`**. New `name` = add; existing `name` = replace that field's def wholesale
(clone-then-change to edit one key). This is additive and upgrade-safe — no model file is edited, nothing
drifts out of sync. Deliver the mapped column via a migration; a property never creates storage. The full
how-to (contract, casing trap, worked example, external storage) is
`customization/com_configbox_extending_stock_models.md`; the migration side is
`technical/com_configbox_migrations.md`.

### 9.4 Change a screen's toolbar — `getListingTasks()` / `getDetailsTasks()`

`getListingTasks()` (`:1434`, default `Add/Remove/Copy`) and `getDetailsTasks()` (`:1449`, default
`Save and Close/Save/Cancel`) return button definitions the admin templates render into the toolbar.
Each entry is `['title'=>…, 'task'=>…, 'primary'=>bool]`; the `task` is the controller method the button
triggers. Override on a model to add/remove/relabel toolbar actions for that screen — the cleanest way to
surface a custom controller task (§7.1) in the UI.

### 9.5 React to events — observers (no model change at all)

The model fires **`onAfterStoreRecord`** (`:578`), **`onAfterCopyRecord`** (`:812`) and
**`onAfterDeleteRecord`** (`:2037`) through `KenedoObserver`. Registering an observer is the **safest, most
decoupled** way to run code on a model mutation — no core file touched, survives updates, deployable through
the admin Connectors UI. Use this over a `system_overrides/` `afterStore` whenever you only need to *react*
to a save/copy/delete rather than change the save itself. Catalog and signatures:
`customization/com_configbox_events_and_observers.md`.

### 9.6 Modifying `KenedoModel`'s own behavior (the "improve the framework" case)

The base class lives in `external/kenedo/` and is shared across all platforms (Joomla/Magento/WordPress/
standalone), so treat changes as framework-level:

- The extension points are the **overridable methods** — the CRUD methods, `afterStore`/`afterDelete`, the
  child-tree methods, `getRecordUsageInfo`, `getListingTasks`/`getDetailsTasks`. Prefer overriding a method
  (or adding a new soft-abstract hook) over inlining logic into `store()`/`getRecords()`.
- **Models are real singletons, but they no longer cache record data** — only property/metadata
  (`memoGetProperties`, `memoPropertyClassNames`). Mutating data outside the model's own
  `store()`/`delete()` (raw SQL, another model writing the same table) needs no cache-busting: the next
  read is a query. **`forgetRecord($id)`** and **`forgetRecords()`** still exist as deprecated no-ops,
  so old code calling them stays correct — see §4.1.
- **`self::$copyIds`** is **static** and accumulates across a copy recursion — it's request-scoped state, not
  per-instance. Don't rely on it outside a `copy()` call chain.
- **No DI, no namespaces, statics everywhere** (`getModel`/`getDb`, `KLog`/`KText`/`KenedoObserver`).
  Match the surrounding style; don't introduce PSR/Joomla-MVC idioms into Kenedo code. The one sanctioned
  modernization is the database layer's `setPreparedQuery()` — use it for new queries instead of manual
  escaping (see `com_configbox_kenedo_mvc.md` §8 for the framework-wide caveats and the realistic
  modernization anchors).

**Gotchas worth internalizing:**
- The model **knows no columns** — everything routes through `getProperties()`; to understand a query, read
  the *properties*, not the model.
- Model classes are **core-first / new-only** (`system_overrides/` to change core); property *types* are
  **customization-first / shadowing** — opposite directions (§6.2).
- Two `afterStore`s exist — the **model's** `afterStore($id, $wasInsert)` (inside the txn) and the
  **controller's** `afterStore($success)` (after the save). Pick the right one.
- Every `getRecord()`/`getRecords()` call runs a query — results are **fresh per call** — but records
  from one `getRecords()` result set still come back **by reference**, so `clone` before mutating (as
  `copy()` does) or you rewrite the array your caller is holding.
- `getRecord()` returns **`null`** on a miss; guard it.
- Legacy model names still resolve via `getModel()` with a logged warning — the `Admin*` prefix shim,
  the CB4 aliases (`Adminelements`→`Questions`, `Adminoptions`/`Adminoptionassignments`/
  `Adminxrefelementoptions`→`Answers`) and the rename maps (`ConfigboxModelOrder`,
  `ConfigboxModelGrandorder`, `CbcheckoutModelOrder`) alike (§2.2) — don't write new code against any
  of them.

---

## See also

- `technical/com_configbox_property_types.md` — the **property type catalogue**: what each `type` does,
  its settings, its storage kind and column, plus `storeExternally` (§3) and a selection table (§4).
  One article per type under `technical/property-types/`.
- `technical/com_configbox_property_definition_settings.md` — the storage keys behind a definition:
  `dataType`, `nullable`, `unique`, `maxLength`, and the schema-drift check.
- `technical/com_configbox_kenedo_controller.md` — the **controller layer** in depth (the write-path caller,
  §7.1): request dispatch, the `store()` flow, the task catalog, authorization.
- `technical/com_configbox_kenedo_view.md` — the **view layer** in depth (the read-path caller, §7.2): CRUD
  vs multi-purpose views, the property-loop form/list templates, assets, `.view-<name>` CSS isolation.
- `technical/com_configbox_kenedo_mvc.md` — the whole-MVC overview: the request lifecycle, the base task
  table, and especially **§4 the property model** (definition keys, the lifecycle-hook table, external
  storage, the type catalog) and **§7** the customization/override precedence rules.
- `technical/com_configbox_mvc_tasks.md` — each base task in detail, with the deep dive on **`copy()`**
  (recursive deep-clone + the two-pass `copyRulesAndCalculations` id remapping, §8.2).
- `customization/com_configbox_extending_stock_models.md` — the how-to for **§9.3** (`model_property_customization/`):
  contract, add-vs-override-by-`name`, clone-then-change, the column-delivery migration.
- `customization/com_configbox_overriding_controllers_and_models.md` — the per-kind precedence rules and
  when to reach for `system_overrides/` (**§9.2**), plus the `Cbcheckout→Configbox` fallback (§2.2).
- `customization/com_configbox_custom_properties.md` — building a new property *type* (the customization-first
  resolution of **§6.2**) and the full property lifecycle.
- `customization/com_configbox_events_and_observers.md` — the model's `onAfter{Store,Copy,Delete}Record`
  events (**§9.5**), signatures, and deployment.
- `technical/com_configbox_kenedo_database.md` — the database layer under all of this:
  `setQuery`/`getQuoted`/`getEscaped`, `setPreparedQuery`, the `load*` family, `insertObject`, and the
  savepoint-emulated nested transactions `store()`/`copy()`/`delete()` lean on.
- `technical/com_configbox_migrations.md` — delivering the DB column a new property def maps (never `ALTER`
  by hand).
- Key source: `external/kenedo/classes/KenedoModel.php` (`getModel` `:158`, `store` `:476`, `afterStore`
  `:630`, `copy` `:649`, `copyRulesAndCalculations` `:876`, `getCustomPropertyDefinitions` `:1169`,
  `getProperties` `:1243`, `getPropertiesForAllPlatforms` `:1289`, `getBaseProperties` `:1316`,
  `getPropertyObject` `:1364`, `getPropertiesForListing` `:1414`, `getListingTasks`/
  `getDetailsTasks` `:1434/:1449`, `initData` `:1476`, `getRecordClass`/`hydrateRecord` `:1556/:1591`,
  `getRecord` `:1616`, `getRecords` `:1694`, `publish`
  `:1924`, `delete`/`canDelete` `:1970/:2093`, `storeOrdering` `:2126`, `getRecordUsage` `:2157`),
  `external/kenedo/classes/KenedoProperty.php` (constructor + `$model` back-reference `:72`), and models
  `countries` (minimal CRUD), `products`/`pages`/`questions` (parent→child tree),
  `calculations` (`afterStore` + `getRecordUsageInfo`).
