Extending Stock Models (adding/overriding fields)
- Version
- 4.0 preview
- Updated
How to add a field to an existing CBX model — or tweak how an existing field appears — without
editing the core model file. This is the model_property_customization/ mechanism: you contribute extra
property definitions that the framework merges into a stock model's own definitions. Because a
model's property definitions drive its form, its listing, its SQL and its storage (see the Kenedo MVC
doc), one definition added here gives you a new form widget, an optional list column, request parsing,
validation, the SELECT and storage — all without touching the model.
This is the merge-style extension point (your defs are combined with the core's, not substituted for
the whole model). It pairs with com_configbox_custom_properties.md: that guide builds a new field type;
this guide uses a type (built-in or custom) on an existing model.
Read com_configbox_customization_overview.md first. All paths are relative to the component root
docroot/components/com_configbox/. Source references are point-in-time — verify against the code.
1. When to use this (vs. shadowing the model)
| You want to … | Use |
|---|---|
| Add a field to a stock model's form/list | This mechanism (model_property_customization/) |
| Change how a stock field appears (list position, hide from listing, tooltip, order) | This mechanism (re-declare the field by name) |
| Add a new field type/widget that doesn't exist yet | properties/ — com_configbox_custom_properties.md |
| Change a model's behavior (a method, a query, validation logic beyond a property) | A system_overrides/ class replacement — the customization models/ folder can't shadow a core model (core-first). See com_configbox_overriding_controllers_and_models.md |
Prefer this mechanism whenever the change is "data shape" rather than "behavior": it is additive, it
doesn't copy the core model (so it never drifts out of sync), and it survives updates cleanly. For genuine
behavior changes note that you cannot shadow a core model by dropping a same-named file in
data/customization/models/ — that loader is core-first (it loads the customization file only for
new model names). Changing a core model's methods means a system_overrides/ class replacement; see the
controllers-and-models guide.
2. The contract (file name, function name, return value)
The merge is implemented in KenedoModel::getCustomPropertyDefinitions()
(external/kenedo/classes/KenedoModel.php). For a model class ConfigboxModelProducts:
$ownBase = strtolower( substr(get_class($this), strpos(get_class($this), 'Model') + 5) ); // "products"
$path = getDirCustomization().'/model_property_customization/'.$ownBase.'.php'; // products.php
include_once($path);
$function = 'customPropertyDefinitions'.ucfirst($ownBase); // customPropertyDefinitionsProducts
$defs = $function(); // your defs
So the contract has three exact parts:
| Part | Rule | Example (ConfigboxModelProducts) |
|---|---|---|
| File | data/customization/model_property_customization/<modelname>.php, where <modelname> is the class name lowercased, with the leading ConfigboxModel removed | …/model_property_customization/products.php |
| Function | customPropertyDefinitions<Modelname>() — note <Modelname> is ucfirst() of the file base (so the first letter uppercase, the rest as-is) | function customPropertyDefinitionsProducts() |
| Return | an array of property definitions, keyed by each def's name | return $propDefs; |
Finding the model name: it's the class after
ConfigboxModel— model classes carry no Admin prefix (ConfigboxModelProductsinmodels/products.php,ConfigboxModelQuestionsinmodels/questions.php) → fileproducts.php, functioncustomPropertyDefinitionsProducts. Get the casing of the function wrong and the framework silently skips your file (function_exists()check) — no error, your field just doesn't appear. This is the #1 mistake; double-check it.
Two behaviours of the current loader worth knowing:
- Legacy aliases. A model that absorbed or renamed another one lists its old file bases in
getLegacyCustomizationAliases(), so a customization file from the retired admin-prefixed era (adminproducts.php) still loads — the model's own canonical file wins on key collisions. New code always targets the canonical name. - Defensive loading. A customization function that throws (typically because it references something a core refactor removed) is logged and skipped, not fatal — the admin keeps working without your fields rather than white-screening. Check the CBX log when injected fields vanish after an update.
3. How the merge works (add vs. override by name)
getProperties() merges your defs into the model's own (KenedoModel.php):
$propDefs = $this->getPropertyDefinitions(); // core defs, keyed by name
$customPropDefs = $this->getCustomPropertyDefinitions(); // your defs, keyed by name
$propDefs = array_merge($propDefs, $customPropDefs); // ← custom wins on key collision
uasort($propDefs, array('KenedoModel', 'sortProperties')); // re-sorted by positionForm
Two outcomes follow from array_merge keying on name:
- A
namethe core model doesn't have → the field is added. - A
namethe core model does have → your def replaces the core def for that field, wholesale. (It's a replace, not a deep-merge: whatever keys you return become the field's full definition.) The common, safe pattern for "I only want to tweak one thing" is to clone the original def, then change it — see §5.
After merging, all defs are re-sorted by positionForm, so a newly added field lands wherever its
positionForm places it in the form (and positionList in the listing).
4. Worked example — add a "Care instructions" field to products
Add a multi-line text field to the product admin form, store it in a side table (so no core table is
altered), and show it as a sortable list column. The field uses the built-in string type — no custom
property type needed.
4.1 The schema (migration first)
A property only maps storage; it never creates it. Deliver the column via a customization migration
(data/customization/updates/<version>.php), guarded for idempotency. Here we add a side table so we
don't touch the core product table:
// data/customization/updates/0.0.1.php (customization migration track)
defined('CB_VALID_ENTRY') or die();
if (ConfigboxUpdateHelper::tableExists('#__configbox_product_care') == false) {
$db = KenedoPlatform::getDb();
$db->query("
CREATE TABLE `#__configbox_product_care` (
`product_id` INT UNSIGNED NOT NULL PRIMARY KEY,
`care_instructions` TEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
Spell out both charset AND collation — exactly as above, matching
ConfigboxUpdateHelper::TARGET_CHARSET/TARGET_COLLATION.CHARSET=utf8mb4without aCOLLATEclause does not inherit the database's collation; it takes the charset's server-dependent default (utf8mb4_0900_ai_cion MySQL 8), and the mismatch surfaces months later asIllegal mix of collationson the first query that joins across it. Keep side-table value columnsNULL-able: when several fields share the side table, every save writes the whole row, including columns whose fields don't apply to the record being saved — aNOT NULLcolumn for one feature then breaks saving records of every other type.
See technical/com_configbox_migrations.md for the customization migration track, versioning, and the
ConfigboxUpdateHelper guards (tableExists(), tableFieldExists()). Never ALTER TABLE by hand.
4.2 The property injection
// data/customization/model_property_customization/products.php
<?php
defined('CB_VALID_ENTRY') or die();
/**
* Extra property definitions merged into ConfigboxModelProducts.
* @return array[]
* @see ConfigboxModelProducts::getPropertyDefinitions
*/
function customPropertyDefinitionsProducts() {
$propDefs = array();
$propDefs['care_instructions'] = array(
'name' => 'care_instructions',
'type' => 'string',
'label' => KText::_('Care instructions'),
'tooltip' => KText::_('Shown on the product page; how the customer should care for the item.'),
'options' => 'USE_TEXTAREA', // string-type flag → multi-line widget
'positionForm' => 250, // where it appears in the edit form (sorted by this)
'positionList' => 0, // 0/omit → not shown as a list column (see note)
'canSortBy' => true,
// The same field explained to somebody reading the API schema (see the note below):
'apiTitle' => 'Care instructions',
'apiDescription' => 'Free-text care instructions shown on the product page. Plain text, may be empty.',
// Store in our own side table instead of the core product table:
'storeExternally' => true,
'foreignTableName' => '#__configbox_product_care',
'foreignTableAlias' => 'product_care',
'foreignTableKey' => 'product_id',
);
return $propDefs;
}
That single definition gives the product admin form a textarea, request parsing + storage into the side
table (the base class does the JOIN, read and upsert because storeExternally is set — see
com_configbox_custom_properties.md §6 for the customization walk-through, and
../technical/com_configbox_property_types.md §3 for the mechanism in full), and a sortable field. No
core model, controller, view or query was touched.
Two constraints on your side table.
foreignTableKeymust carry a UNIQUE index — the write is an upsert relying on the duplicate-key collision, so without it every save inserts a second row instead of updating. AndforeignTableAliasmust be unique among the model's properties, or two external fields collide in the same query; several fields sharing one side table share one alias and one join, which is the intended way to add a group of columns at once.You do not need to clean up on delete: the base
delete()removes the side-table row when the record goes. See../technical/com_configbox_property_types.md§3.
storeExternallyneeds acolumn-kind property type. It moves the property's column to another table, so it does nothing for aderivedtype (translatable,multiselect,taxclassrates,childentries) or alayoutone (groupstart/groupend,note) — those have no base-table column to move. The per-type applicability table is in the property-types overview.
Use
KText::_()for labels so the text is translatable through the i18n system (and overridable vialanguage_overrides/). Real stock injections do exactly this.
Storing through a model: translations must be RE-SUPPLIED on every save. A
translatableproperty reads its per-language values from<field>_<tag_key>keys on the data object (KenedoLanguageHelper::getTranslationKey('title', 'en-GB')→title_en_GB), and a save whose data object is MISSING a language's key deletes that language's string — it does not keep the stored value. This bites import/seed scripts the moment they update a record from aSELECT *row (the row carries no translation keys, so the update silently blanks every title). Always attach every active language's text on every save, whether creating or updating. The demo seeders (tools/setup-demo-*.phpin the cbx-joomla site repo) show the pattern.
4.3 Your field becomes an API field — apiTitle, apiDescription, regeneration
A definition merged into an entity-API model (products, questions, answers, …) is not only a form widget: it becomes a field of that entity's API. It lands in the generated JSON schemas, the PHP record stubs and the TypeScript types, and the entity endpoints read and write it like any stock field. Three consequences:
- Write the API pair.
label/tooltipare worded for an operator standing in front of the form;apiTitle/apiDescriptionare the same field explained to somebody reading a schema. Both fall back to the admin pair, so a field whose admin wording already reads correctly to a developer needs neither — but wording that assumes the form around it ("Choose yes to…") should get proper API prose. The house rule for new fields: give every stored property the pair rather than debating each one. Writing guidance (noun-phrase titles, what to state in the description):../technical/com_configbox_property_definition_settings.md§9. - Regenerate the artifacts. After adding, removing or retyping a property, run
php cli/joomla.php configbox:generate-typesand commit the changedgenerated/files with the customization (--checkfails while they disagree, so a CI gate catches a forgotten run). The API reference/Postman export isconfigbox:api:export. Customization-added fields are marked (x-configbox-origin), and conditional ones carry theirappliesWhenin the schema. - Your keys are your API. Renaming a property
namelater breaks stored data and every API client that learned the field. Choose names as carefully as you would for a core field, and prefix them per feature (care_,dim_,stab_) so they cannot collide with future core fields. - The scope area doesn't change. A field merged onto a stock model (products, questions, …) is
gated by that entity's existing scope area (
catalogfor a product field) — adding a field never needsKenedoModel::getApiArea(). That method matters only for a whole new model your customization adds viagetEntityName(); seecom_configbox_overriding_controllers_and_models.md§4.
5. Tweaking an existing field (clone-then-change)
To change how a stock field behaves — move it in the listing, hide it from the list, drop its filter —
re-declare it by the same name, but start from the original def so you only change what you mean
to. This is the pattern the shipped customizations use:
function customPropertyDefinitionsAdminproducts() {
$propDefs = array();
// Pull the model's own definitions so we can clone + tweak individual fields.
$originalDefs = KenedoModel::getModel(ConfigboxModelProducts::class)->getPropertyDefinitions();
// Move 'published' to the far right of the listing:
if (isset($originalDefs['published'])) {
$propDefs['published'] = $originalDefs['published'];
$propDefs['published']['positionList'] = 40000;
}
// Hide 'baseprice' from the listing (keep it on the form):
if (isset($originalDefs['baseprice'])) {
$propDefs['baseprice'] = $originalDefs['baseprice'];
unset($propDefs['baseprice']['positionList']);
}
return $propDefs;
}
Key points:
getPropertyDefinitions()returns the raw definition arrays (not property objects), which is exactly what you want to clone and modify. Calling it on the model is safe here.- Guard with
isset()before cloning — field names change across versions; a missing key would otherwise throw and break the whole form. - Because your returned def replaces the core one wholesale (§3), cloning-then-changing is the only safe way to "edit one key" — returning a partial def would drop everything you didn't copy.
⚠️ Watch for re-entrancy. Calling
getPropertyDefinitions()inside the injection function is fine (it returns the core defs and does not re-invoke the merge), but do not callgetProperties()from here — that triggers the merge again. Stick togetPropertyDefinitions()for cloning.
6. Deployment checklist
data/customization/
model_property_customization/
<modelname>.php ← fn customPropertyDefinitions<Modelname>() returning defs keyed by name
updates/
<version>.php ← migration: create the column/table the new field maps
properties/ ← (only if the field needs a brand-new type — see custom-properties guide)
- Identify the model name — the class after
ConfigboxModel, lowercased, for the file;ucfirst()of that for the function. Get the casing exactly right or the file is silently skipped (§2). - Deliver the schema first via a customization migration, guarded with
ConfigboxUpdateHelper(tableExists/tableFieldExists). The property maps an existing column; it never creates one. - Write the injection function, returning defs keyed by
name. Newname= add; existingname= override (clone-then-change, §5). - Pick the type — a built-in type, or your own from the custom-properties guide. Use
KText::_()for labels. - Use
positionForm/positionListto place the field (everything is re-sorted bypositionForm). - Write
apiTitle/apiDescriptionon every stored property (§4.3) and regenerate the artifacts:php cli/joomla.php configbox:generate-types, commitgenerated/together with the change. - Verify manually — load the admin form, save, reload the record, check the listing column/sort, and confirm storage landed where you intended. CBX has zero automated tests.
7. Conventions & gotchas
- Casing of the function is fatal-by-silence.
customPropertyDefinitions+ucfirst($fileBase). Wrong case →function_exists()fails → your file is ignored with no error. (A function that exists but throws is different: it is logged and skipped — check the CBX log.) - Keys are
names; merge replaces by name. Samenameas core = full replacement of that field's def. Clone-then-change to edit one key (§5). - Schema is the migration's job. Add the column/table in
updates/, idempotently. Don'tALTERby hand and don't expect the property to create storage. - Guard
isset()when cloning core defs — field names drift across versions. storeExternallykeeps you off the core table — adding a side table avoids altering shipped tables and the merge conflicts that come with it. Seecom_configbox_custom_properties.md§6 for the foreign-table keys and../technical/com_configbox_property_types.md§3 for the full mechanism.- Pick the
typedeliberately. The field you are adding almost certainly has a shipped type that fits —translatableif a customer reads it,numberfor anything numeric,joinfor a reference,jsonfor a JSON column (neverstring, which HTML-escapes and corrupts it). The selection table is in../technical/com_configbox_property_types.md§4, with one article per type under../technical/property-types/. - One file per model. All injected defs for a model go in its single
model_property_customization/<modelname>.php; the loader includes exactly that one file. - Match the idioms. Plain functions (not classes), no namespaces,
KText::_()for strings — follow the shippedmodel_property_customization/files as your reference.
See also
com_configbox_customization_overview.md— the extension-point map (this is the merge row).../technical/com_configbox_property_types.md— whichtypeto give your field: the settings every type accepts, the storage kinds,storeExternally(§3), the selection table (§4), and one article per type under../technical/property-types/.../technical/com_configbox_property_definition_settings.md—dataType,nullable,unique,maxLengthfor the column your migration delivers; §9 for writingapiTitle/apiDescription.com_configbox_custom_properties.md— build a new field type to use here; the full property-definition key reference (§5) and external storage (§6).com_configbox_overriding_controllers_and_models.md— when you need to change model behavior, not just add a field.technical/com_configbox_kenedo_mvc.md— how property definitions drive form/list/SQL/storage.technical/com_configbox_migrations.md— delivering the column/table the new field maps (customization track).external/kenedo/classes/KenedoModel.php—getCustomPropertyDefinitions()(:802), the merge ingetProperties()(:830).