KenedoModel, in Detail
- Version
- 3.x
- Updated
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:
getTableName()— the base table (#__configbox_countries).getTableKey()— the primary key column (id).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
KenedoPropertyobjects do. Every method that touches data (getRecord,getRecords,store,validateData,delete,copy, …) is aforeachovergetProperties()that delegates the field-specific work to each property. The model orchestrates; the properties do the SQL, validation, storage and rendering. (KenedoPropertyis the other half — seecom_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 registryself::$instancesis actually populated and reused (:228/:276/:280), likeKenedoControllerand unlikeKenedoView(whose "singleton" is vestigial; seecom_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()onKenedoControllereach 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 noparent::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
- whose cell links to the edit form, request parsing, required-validation, the SELECT, and storage into
the base table. The
zonesmultiselectmaps a many-to-many xref table. Thegroupstartis a form-layout-only property (a visual fieldset, no column). Seecom_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 overridestore()/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
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, usesystem_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 andcustomization/com_configbox_overriding_controllers_and_models.md.) - Real singleton. Unlike
KenedoView::getView()(fresh instance every call), the model registryself::$instancesis 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 forConfigboxModelProductsshare 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): theadminprefix was removed from all model class names, and a regex rewrite servesConfigboxModelAdmin<X>asConfigboxModel<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,AdminoptionassignmentsandAdminxrefelementoptionsall resolve toConfigboxModelAnswers— and the element→question rename mapsAdminelementstoConfigboxModelQuestions. - The
Cbcheckout→Configboxfallback (MERGELEGACY,:235-237,:266-273): if the component resolves tocom_cbcheckoutit's rewritten tocom_configbox, and ifConfigboxModel<X>isn't defined butCbcheckoutModel<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:
-
Custom defs merge in.
getCustomPropertyDefinitions()(:1169) pulls extra/overriding definitions fromdata/customization/model_property_customization/<modelname>.php(a plain functioncustomPropertyDefinitions<Modelname>()returning defs keyed byname). This is themodel_property_customization/extension point — the additive, upgrade-safe way to add a field to a stock model without editing it. It's anarray_mergekeyed byname, so anamecore already has is replaced wholesale; a newnameis added (§9.3, and the how-to incustomization/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 viagetLegacyCustomizationAliases()(:1153) so old per-site customization files keep loading. -
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 separatepositionListsort ingetPropertiesForListing(),:1414.) -
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 andisRequired()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 bygetPropertyDefinition('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_entitydisagreed with each other — describe advertisedbasepriceon 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 withx-configbox-platformsandx-configbox-platform-defaults. -
Each def becomes a typed
KenedoProperty<Type>object viagetPropertyObject()(:1364, §6). Thetypestring selects the class; the def array configures it. -
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 asgetProperties()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 whatevermodel_property_customizationfiles 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 asnew 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 (ajoinproperty, for instance, resolves another model viaKenedoModel::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()andgetRecords()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 usedempty()rather thanisset(), 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
UPDATEbetween 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()andforgetAllRecords()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. Seeproperty-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 fromgetEntityName()—'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 extendstdClass, hold shape and never behaviour, so dynamic properties stay allowed and every loose consumer habit —$record instanceof stdClassincluded — keeps working. - Casting. mysqli hands every value over as a string;
castRecordValues()(:1120) runs at the end ofgetRecord()andgetRecords()and casts the model's numeric columns to real PHP ints and floats, per thegetCastMap()metadata memo (:1080, built from the declareddataTypes, so the definitions stay the one source). NULL stays NULL. Booleans deliberately stay'1'/'0'strings — the codebase compares them loosely everywhere, and a realboolwould 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)contributesWHEREfragments (:1723). Filter names come fromgetFilterNames()(:1877, which asks each propertygetFilterName()). - Sorting —
$sortSpecs(an array of{propertyName, direction}) is resolved toORDER BYcolumn references (:1731-1788). Ajoinproperty sorts by the related model'spropNameDisplaycolumn — the code resolves the parent model viaKenedoModel::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 dropsORDER BY/LIMITand 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 apropertyNamewith a.in it are split, escaped and quoted (:1758-1759) — but a metadata-derived identifier is otherwise trusted. getRecordUsage()(:2157) interpolatestitleField/fkField/filterFielddirectly from the model's owngetRecordUsageInfo()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:
defaultvalues 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
joinproperty, 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:
- Base-table phase. Properties that live in the model's own table return their column name(s) from
getDataKeysForBaseTable(); the model collects those into$baseDataand does oneinsertObject()(which INSERTs when the PK is empty, UPDATEs otherwise). Properties stored elsewhere return[]here. Two coercions keep the row valid under the connection's strictsql_mode(seeKenedoDatabase::connect()— the historicalsql_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 declareddefault, else a type-appropriate empty (0for 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 ofgetProperties()(§3) but still owns a real column here, typically NOT NULL without a DB default. On an INSERT the loop overgetPropertiesForAllPlatforms()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.
- NULL on a NOT-nullable column (
- Side-data phase. Every property's own
store($data)runs — atranslatableupserts into#__configbox_strings, a property withstoreExternallyupserts into its foreign table (INSERT … ON DUPLICATE KEY UPDATE), achildentrieswrites child rows, amultiselectrewrites its xref rows. A property that cannot store its data throws; afalsereturn is turned into aKenedoSystemExceptionhere. 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'sgetDataFromRequest(&$data)pulls its field(s) from the request into a normalizedstdClass.prepareForStorage($data)(:353) → each property'sprepareForStorage()auto-fills/normalizes (afalsebecomes a thrown refusal).validateData($data, $context)(:381) → for each property thatapplies($data)(:389, honoring conditional-applicability), callcheck($data); refusals are absorbed into oneKenedoValidationExceptionthat 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:
| 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, 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 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):
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:
- Clone
$data, null the primary key, build the base row (samegetDataKeysForBaseTable()loop asstore, but asking each propertygetValueForCopy()— a unique column has to become something else on a copy, and only the property knows how),insertObject()→ a new id. - Record the mapping in the static
self::$copyIds[$modelName][$oldId] = $newId(:706) — the cross-record id map two-pass rule-remapping needs (below). - Loop properties, calling each
$property->copy($data, $newId, $oldId)(side-data clone,:723). - 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 ofID ") and rethrows, so the reason arrives at the controller with its identity intact rather than the Nth flattening of it. - 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 bystore()and always has been, so a failure reported that way is silently lost — thecalculationsmodel relies on exactly that and logs instead. (Distinct from the controller'safterStore($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 oflist/read/create/update/deletethe entity supports; singleton entities (settings, store record) narrow it.getAugmentedRecordKeys()(:110) — record keys agetRecord()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$dataobject 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 intostore()/getRecords(). - Models are real singletons, but they no longer cache record data — only property/metadata
(
memoGetProperties,memoPropertyClassNames). Mutating data outside the model's ownstore()/delete()(raw SQL, another model writing the same table) needs no cache-busting: the next read is a query.forgetRecord($id)andforgetRecords()still exist as deprecated no-ops, so old code calling them stays correct — see §4.1. self::$copyIdsis static and accumulates across a copy recursion — it's request-scoped state, not per-instance. Don't rely on it outside acopy()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'ssetPreparedQuery()— use it for new queries instead of manual escaping (seecom_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'safterStore($id, $wasInsert)(inside the txn) and the controller'safterStore($success)(after the save). Pick the right one. - Every
getRecord()/getRecords()call runs a query — results are fresh per call — but records from onegetRecords()result set still come back by reference, soclonebefore mutating (ascopy()does) or you rewrite the array your caller is holding. getRecord()returnsnullon a miss; guard it.- Legacy model names still resolve via
getModel()with a logged warning — theAdmin*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 eachtypedoes, its settings, its storage kind and column, plusstoreExternally(§3) and a selection table (§4). One article per type undertechnical/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, thestore()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 oncopy()(recursive deep-clone + the two-passcopyRulesAndCalculationsid 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 forsystem_overrides/(§9.2), plus theCbcheckout→Configboxfallback (§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'sonAfter{Store,Copy,Delete}Recordevents (§9.5), signatures, and deployment.technical/com_configbox_kenedo_database.md— the database layer under all of this:setQuery/getQuoted/getEscaped,setPreparedQuery, theload*family,insertObject, and the savepoint-emulated nested transactionsstore()/copy()/delete()lean on.technical/com_configbox_migrations.md— delivering the DB column a new property def maps (neverALTERby 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 +$modelback-reference:72), and modelscountries(minimal CRUD),products/pages/questions(parent→child tree),calculations(afterStore+getRecordUsageInfo).