Skip to main content
Version: 3.x

KenedoModel, in Detail

Version
3.x
Updated
View markdown

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:

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

FileClassRole
models/countries.phpConfigboxModelCountriesthe model (this doc)
controllers/admincountries.phpConfigboxControllerAdmincountriesthe controller (drives the tasks)
views/admincountries/view.html.phpConfigboxViewAdmincountriesthe list view
views/admincountry/view.html.phpConfigboxViewAdmincountrythe 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 (ConfigboxModelCountriescountries), 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

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:

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

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

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 dataTypes, 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.
  • PaginationLIMIT 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:

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

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

ResolutionDirectionTo change an existing core one
Model class (getModel, :244-252)core-first / new-onlysystem_overrides/
Property type (getPropertyObject, :1374-1379)customization-first / shadowsdrop 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, one article per type under 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 taskModel call(s)
store() / apply() / storeAndNew() (KenedoController.php:371)getDataFromRequest, then the shared pipeline (ConfigboxEntityApiHelper::runStorePipeline()): prepareForStorageisInsertvalidateDatastore; 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 methodProperty hook it loops
getDataFromRequestgetDataFromRequest(&$data)
prepareForStorageprepareForStorage(&$data)
validateDataapplies($data) then check($data)
store (base)getDataKeysForBaseTable($data)
store (side)store(&$data)
getRecord/getRecordsgetSelectsForGetRecord, getJoinsForGetRecord, getGroupingColumnsForGetRecord, getWheres, then appendDataForGetRecord(&$record)
getFilterNamesgetFilterName()
deletecanDelete($id), then delete($id, $table)
copycopy($data, $newId, $oldId)
publishfinds 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):

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 ID ") 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 afterStores 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 (AdminelementsQuestions, Adminoptions/Adminoptionassignments/ AdminxrefelementoptionsAnswers) 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).