Skip to main content
Version: 3.x

Extending Stock Models (adding/overriding fields)

Version
3.x
Updated
View markdown

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/listThis 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 yetproperties/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:

PartRuleExample (ConfigboxModelProducts)
Filedata/customization/model_property_customization/<modelname>.php, where <modelname> is the class name lowercased, with the leading ConfigboxModel removed…/model_property_customization/products.php
FunctioncustomPropertyDefinitions<Modelname>() — note <Modelname> is ucfirst() of the file base (so the first letter uppercase, the rest as-is)function customPropertyDefinitionsProducts()
Returnan array of property definitions, keyed by each def's namereturn $propDefs;

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

Two behaviours of the current loader worth knowing:

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

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

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

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

Two outcomes follow from array_merge keying on name:

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

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


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

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

4.1 The schema (migration first)

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

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

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

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

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

4.2 The property injection

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

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

$propDefs = array();

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

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

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

return $propDefs;
}

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

Two constraints on your side table. foreignTableKey must carry a UNIQUE index — the write is an upsert relying on the duplicate-key collision, so without it every save inserts a second row instead of updating. And foreignTableAlias must be unique among the model's properties, or two external fields collide in the same query; several fields sharing one side table share one alias and one join, which is the intended way to add a group of columns at once.

You do not need to clean up on delete: the base delete() removes the side-table row when the record goes. See ../technical/com_configbox_property_types.md §3.

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

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

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

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

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

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

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

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

function customPropertyDefinitionsAdminproducts() {

$propDefs = array();

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

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

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

return $propDefs;
}

Key points:

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

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


6. Deployment checklist

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

7. Conventions & gotchas

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

See also

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