Skip to main content
Version: 4.0 preview

Creating Custom Kenedo Properties

Version
4.0 preview
Updated
View markdown

A practical, end-to-end guide to building your own Kenedo Property — the unit that turns one line in a model's property-definition array into a form widget, a list column, request parsing, validation, SQL, storage, and participation in copy/delete. After reading this you should be able to write, deploy, style and script a custom property type without touching any controller, view, or query code.

This is the how-to-build companion to technical/com_configbox_kenedo_mvc.md (which explains the framework) and technical/com_configbox_migrations.md (which explains how the DB column a property needs is delivered). Read §4 of the Kenedo MVC doc first if the term "property-driven model" is new to you.

Before writing a new type, check whether one already fits. The shipped catalogue is technical/com_configbox_property_types.md — the settings every type accepts, the storage kinds, storeExternally, and a "which type do I want" table — with one article per type under technical/property-types/. A custom type is the right answer surprisingly rarely: string with USE_TEXTAREA, a dropdown with a modelClass, or a json column covers most of what people reach for a new class to do.

All paths below are relative to the component root docroot/components/com_configbox/ unless stated otherwise. Source references are point-in-time (component 3.4.1) — verify against the code.


1. What a property is (and is not)

A property is a reusable field type. A property definition is one use of that type inside a specific model. The type is code; the definition is data.

// In a model's getPropertyDefinitions() — this is a *definition*, not a type:
$propDefs['color'] = array(
'name' => 'color', // column / request key / data key
'type' => 'string', // ⇒ the KenedoPropertyString *type*
'label' => 'Color',
);

One type (string, boolean, or your own colorpicker) is reused by many definitions across many models. When you write a custom property you are writing the type: a PHP class plus, usually, a form template. You are not writing it for one field — you are extending the framework's vocabulary.

The base class KenedoProperty (external/kenedo/classes/KenedoProperty.php) already implements every hook with a sensible default. A custom property overrides only the hooks whose default behavior it needs to change. The simplest useful property is a class with a single overridden method plus a template.


2. How a type name resolves to your code

This is the contract you must satisfy for CBX to find your property. It is enforced in KenedoModel::getPropertyObject() (external/kenedo/classes/KenedoModel.php:874) for the class and KenedoProperty::getAdminTemplateFile() (KenedoProperty.php:848) for the template.

Given a definition with 'type' => 'colorpicker':

ConcernLooked up asSearch order (first match wins)
Class filecolorpicker.php (strtolower(type).'.php')data/customization/properties/external/kenedo/properties/
Class nameKenedoPropertyColorpicker ('KenedoProperty'.ucfirst(strtolower(type)))must be defined in that file
Form templatecolorpicker.php (strtolower(type).'.php')data/customization/properties/tmpl/external/kenedo/properties/tmpl/

Three things follow from this, and they trip people up:

  1. The customization folder shadows the system folder. Dropping data/customization/properties/string.php overrides the built-in string type everywhere. Use a new type name unless you deliberately want to replace a stock type for the whole install.

  2. The class name is derived case-insensitively. The resolver builds the name as ucfirst(strtolower($type)), so 'type' => 'colorPicker' yields KenedoPropertyColorpickernot KenedoPropertyColorPicker. PHP class names are case-insensitive, so a camelCase class declaration still works (the stock KenedoPropertyGroupPrice is reached from 'type' => 'groupPrice' exactly this way). Recommendation: use an all-lowercase, single-word type name (colorpicker, not colorPicker) so the file name, class name and type string line up with no surprises. If you must use camelCase in the type string, name the file all-lowercase (colorpicker.php) and you may name the class either way.

  3. A template is REQUIRED, and it is found by name. If no file exists for the type in either folder, getBodyAdmin() throws — and because one property brings the whole admin form down with it, the symptom is a dead edit screen rather than one blank field. Fail-fast is deliberate: a property that silently renders nothing is a field that silently stops being editable, which nobody notices until data is missing.

    (This used to be documented as optional, returning NULL for an empty body. It is not, and never quietly renders empty — if you read that here before, this is the correction.)

    There are exactly three ways to satisfy it, all named in the exception message:

    • ship data/customization/properties/tmpl/<lowercase-type>.php — the normal answer;
    • override getAdminTemplateFile() to point at another type's template, when your type is a thin variant of an existing widget;
    • override getBodyAdmin() and return the HTML yourself, for display-only properties or widgets built entirely in PHP/JS.

    Whichever you choose, decide it when you create the type. A custom property with no template is not a half-finished feature — it is a broken admin screen.

The resolved class name and template path are memoized per request (memoPropertyClassNames), so the file-existence checks cost nothing after the first use.


3. The property lifecycle — every hook, in call order

The model and view drive a property through distinct phases. Each phase calls specific methods on every property in the model. Override the ones you need; leave the rest to the base class.

3.1 Construction & identity

__construct($propertyDefinition, KenedoModel $model) (:69) runs once per property per model load. The base constructor:

  • stores $this->propertyName (= the definition's name) and the whole $this->propertyDefinition,
  • calls modernizeOldPropertySettings() to upgrade legacy key names (listingpositionList, listinglinkmakeEditLink, …) — so you can ignore the old names,
  • builds the wrapper CSS id property-name-<name> and CSS classes property-name-<name> kenedo-property property-type-<type> form-group (+ required / invisible-field when applicable),
  • parses the space-separated options string into the optionTags map (see §5).

You rarely override the constructor. If you do, call parent::__construct() first. Read configuration from the definition via $this->getPropertyDefinition('key', $default) (a final accessor, :590) — do not re-read $this->propertyDefinition directly.

3.2 Read phase — building the SELECT

When the model loads a record (getRecord) or a list (getRecords) it asks each property for its SQL:

  • getSelectsForGetRecord($prefix, $override) (:882) → array of table.col AS \alias`strings. Default selects the property's own base-table column under its select alias. Override to select nothing (display-only fields — seenote.php`), to select multiple columns, or to select a computed expression.
  • getJoinsForGetRecord() (:897) → array of LEFT JOIN … strings, keyed by table alias (the key de-dupes joins). Default adds the join for an externally-stored column (§6); returns empty otherwise.
  • getGroupingColumnsForGetRecord(), appendDataForGetRecord(&$data), appendDataForPostCaching(&$data) — optional post-query massaging of the loaded row.

The selected value lands on the record object under the property's select alias, which by convention is $data->{$this->propertyName}.

3.3 Render phase — the form widget

KenedoView loops the model's properties and, for each, calls setData($record) then getPropertyFormOutput($record) (:519). That method emits the standard wrapper and delegates the inner widget to getBodyAdmin() (:716), which requires the resolved template file with $this bound to the property. So the normal way to render a widget is the template, not PHP in the class:

// getPropertyFormOutput produces, when usesWrapper() is true:
<div id="property-name-color" class="property-name-color kenedo-property property-type-colorpicker form-group"
data-property-definition="…json…">
<div class="property-label"><?= getLabelAdmin() ?></div> <!-- unless doesShowAdminLabel() is false -->
<div class="property-body"><?= getBodyAdmin() ?></div> <!-- your template's output -->
</div>

Hooks that shape this:

  • getBodyAdmin() — returns the widget HTML by rendering the template. You normally do not override this; you write the template instead. Override only if you want to build HTML without a template file or to add logic around the include. With neither a template nor an override it throws, taking the whole form down — see §2.3.
  • getLabelAdmin() (:546) — the label cell. Default uses the definition's label (wrapped in a tooltip when tooltip is set).
  • doesShowAdminLabel() (:843) — return false (or set hideAdminLabel in the definition) to suppress the label row (the price-override properties do this).
  • usesWrapper() (:839) — return false to skip the standard wrapper entirely and emit only the body (used by layout properties like groupstart/groupend).
  • renderCssClasses() / getCssId() — wrapper attributes. Add classes by pushing to $this->cssClasses in your constructor if you need extra hooks for CSS/JS.

The template's $this is the property object. Inside the template you have $this->data (the whole record), $this->propertyName, and $this->getPropertyDefinition(...). Read the current value as $this->data->{$this->propertyName}. Always escape output with hsc() and name your form control name="<?php echo $this->propertyName; ?>" so the request phase can find it.

3.4 Write phase — request → validation → storage

On save, the model runs three sub-phases over every property:

  1. getDataFromRequest(&$data) (:242) — pull this property's value out of KRequest and write it onto $data->{$this->propertyName}, sanitizing it. The base implementation reads a string (honoring the ALLOW_RAW / ALLOW_HTML option tags; otherwise KRequest::getString). If the request key is absent it sets the value to NULL. Override to parse composite widgets, decode JSON, normalize numbers, etc. (string.php normalizes the localized decimal mark here.)

  2. prepareForStorage(&$data) (:271) — last-chance massaging before validation (default no-op). Return true. Use for coercions that should happen regardless of validation (string.php forces empty prices to 0 here).

  3. check($data) (:282) — validate. Return true if OK, else throw KenedoValidationException::forProperty($this, $msg). forProperty() attaches your property's name and label, which is what lets the form put the message on the field rather than printing a sentence the operator then has to match to an input. The base implementation enforces required (respecting applies(), §7) and throws the same way, so call it and let it throw — there is no result to inspect and nothing to reset.

    setError() and resetErrors() no longer exist; calling either is a fatal error. Returning false is still read as a refusal, but it cannot carry a reason any more, so the operator gets "reported failed validation but gives no error message". Throw.

    To report more than one problem with the same value, build one exception and absorb() the rest into it — see image.php, which reports size, MIME type and dimensions together rather than making the operator fix and re-upload three times.

    @see ../migration-to-cb4/exceptions-and-validation.md

Then storage runs:

  1. getDataKeysForBaseTable($data) (:305) — which keys of $data belong in the model's base table. Default: just propertyName (or none, if storeExternally). Return array() for non-persistent fields (notes) or extra keys for multi-column fields.

  2. store(&$data) (:322) — persist anything that does not go in the base table, then unset it from $data so the model's own base-table write skips it. The base implementation handles the storeExternally case (§6). Whatever you leave on $data and name in getDataKeysForBaseTable() the model writes to the base table for you.

3.5 Listing phase — the column

When the property appears in a listing (positionList set):

  • getCellContentInListingTable($record) (:611) — the <td> content. Default calls getOutputValueFromRecordData() and, if makeEditLink is set, wraps it in the row's edit link.
  • getOutputValueFromRecordData($record) (:630) — a human-readable rendering of the stored value (e.g. boolean returns "Yes"/"No", string formats prices). This is the most common listing override. Return escaped HTML or plain text.
  • getHeaderCellContentInListingTable($ordering) (:671) — header cell (label + sort controls).
  • Filtering/search: getWheres($filters) (:926), getFilterInput() (:1152), getPossibleFilterValues() (:1188). The base getWheres() handles equality/IN; string.php adds LIKE. boolean.php shows how getPossibleFilterValues() powers a dropdown filter.

3.6 Copy & delete

  • copy($data, $newId, $oldId) (:379) — deep-clone side data when a record is duplicated. Default copies an externally-stored row to the new owner id. Override if your property owns child rows/files that must be cloned. (See technical/com_configbox_mvc_tasks.md for the two-pass copy mechanism.)
  • canDelete($recordId) / delete($id, $tableName) (:474, :486) — guard and clean up side data on delete.

4. A complete worked example — a colorpicker property

A self-contained property that stores a hex color in its own base-table column, renders a native color input, validates the hex format, and shows a colored swatch in listings. It stores in the base table (no external table), so no migration beyond the column itself is needed.

The column must already exist. A property only maps a column; it never creates one. Add the VARCHAR(7) column for color through the migration system (helpers/updates/<version>.php, guarded with ConfigboxUpdateHelper::tableFieldExists()), exactly as for any built-in field. See technical/com_configbox_migrations.md. For a custom property shipped via the customization layer, use the customization migration track (data/customization/updates/).

4.1 The class — data/customization/properties/colorpicker.php

<?php
defined('CB_VALID_ENTRY') or die();

/**
* Stores a hex color (e.g. "#3366ff") in the property's own base-table column.
* Type string 'colorpicker' ⇒ file colorpicker.php, class KenedoPropertyColorpicker.
*/
class KenedoPropertyColorpicker extends KenedoProperty {

/**
* Read + sanitize the submitted value. We only accept a #RRGGBB string; anything
* else becomes NULL so it never reaches the DB unchecked.
*/
function getDataFromRequest(&$data) {

$raw = KRequest::getString($this->propertyName, '');

if (preg_match('/^#[0-9a-fA-F]{6}$/', $raw)) {
$data->{$this->propertyName} = strtolower($raw);
}
else {
// Empty input is allowed (NULL); a present-but-invalid value is rejected in check().
$data->{$this->propertyName} = ($raw === '') ? NULL : $raw;
}
}

/**
* Validate. Required-ness is handled by the parent; we add format validation.
*/
function check($data) {

// Let the base class enforce 'required'. It throws if the value is not acceptable, so there
// is no result to test.
parent::check($data);

$value = $data->{$this->propertyName};

if ($value !== NULL && $value !== '' && !preg_match('/^#[0-9a-f]{6}$/', $value)) {
throw KenedoValidationException::forProperty($this, KText::sprintf('Field %s must be a hex color like #3366ff.', $this->getPropertyDefinition('label')));
}

return true;
}

/**
* Listing cell: a small colored swatch next to the hex value.
*/
function getOutputValueFromRecordData($record) {

$value = $record->{$this->propertyName};

if (empty($value)) {
return '';
}

// $value is validated on write, but escape anyway — defense in depth.
$safe = hsc($value);
return '<span class="cb-color-swatch" style="background:'.$safe.'"></span> '.$safe;
}

/**
* Optional CSS for this property type (swatch styling + the picker layout).
* Collected by KenedoView and injected for any view that contains this property.
* @see docs/customization (optional CSS for properties) and KenedoProperty::getStyleSheetUrls()
*/
function getStyleSheetUrls() {
return array(
KenedoPlatform::p()->getUrlCustomizationAssets().'/css/properties/colorpicker.css',
);
}

}

Notice what we did not write: no SELECT/JOIN SQL (the base selects our base-table column), no store() (the base writes base-table keys for us), no getDataKeysForBaseTable() (default already returns propertyName), no listing edit-link logic. The base class covers all of it.

4.2 The form template — data/customization/properties/tmpl/colorpicker.php

<?php
defined('CB_VALID_ENTRY') or die();
/**
* @var $this KenedoPropertyColorpicker
*/

// Current value, falling back to the definition's 'default'.
$value = isset($this->data->{$this->propertyName}) ? $this->data->{$this->propertyName} : '';
if ($value === '' && $this->getPropertyDefinition('default')) {
$value = $this->getPropertyDefinition('default');
}
?>
<div class="colorpicker-widget">
<input type="color"
class="form-control colorpicker-input"
name="<?php echo $this->propertyName; ?>"
id="<?php echo $this->propertyName; ?>"
value="<?php echo hsc($value !== '' ? $value : '#000000'); ?>" />
<span class="colorpicker-value"><?php echo hsc($value); ?></span>
</div>

Key template rules, all visible above: bind the control's name/id to $this->propertyName; read the value from $this->data; honor default; escape everything with hsc(). The wrapper <div> and label are added by getPropertyFormOutput() — the template emits only the body.

4.3 Using it in a model

Anywhere a model's getPropertyDefinitions() lists fields, add one entry:

$propDefs['color'] = array(
'name' => 'color',
'type' => 'colorpicker', // ⇒ your custom type
'label' => 'Accent color',
'default' => '#3366ff',
'positionForm' => 50, // order in the edit form
'positionList' => 8, // show as list column #8 (omit to hide from listing)
'required' => false,
'tooltip' => 'Pick the accent color shown on the product page.',
);

That single declaration now produces the form widget, the (optional) list column with the swatch renderer, request parsing + hex validation, the SELECT, and base-table storage — across every model that uses it.


5. The property definition reference (keys you can set)

These keys are read by the base class and most properties. They live in the definition array, not your code. (Legacy aliases are auto-upgraded by modernizeOldPropertySettings().)

KeyEffect
nameRequired. Column / request key / data key.
typeRequired. The property type (resolves to your class + template).
labelForm label and default list header.
labelListList header override (when different from the form label).
tooltipAdds a tooltip to the label.
defaultDefault value for new records (read by templates).
requiredEnforced by the base check() (respects applies()).
invisibletrue adds the invisible-field class (hidden but present).
hideAdminLabeltrue suppresses the label row (doesShowAdminLabel()).
positionFormSort order in the edit form.
positionListSort order as a list column; omit to keep the field out of listings.
listCellWidthList column width.
canSortByMake the list column sortable.
addSearchBoxAdd a search box for the column.
addDropdownFilterAdd a dropdown filter (powered by getPossibleFilterValues()).
makeEditLinkWrap the list cell in the row's edit link.
appliesWhenConditional applicability (§7).
optionsSpace-separated flags → optionTags map (e.g. ALLOW_HTML, USE_TEXTAREA).
storeExternally + foreignTable*Store in a side table (§6).

Your own type can read any additional keys you like via getPropertyDefinition('myKey', $default) — e.g. the string type reads stringType, unit, maxLength. Document the keys your type understands at the top of its class.

The per-type keys are catalogued. Every shipped type's own settings — what string, number, image, join and the rest read — are one article each under ../technical/property-types/, indexed by ../technical/com_configbox_property_types.md. Read the article for the type you are modelling yours on; the shared keys above are only half the picture.

(size was split in two because it meant a kilobyte ceiling on a file property and a maxlength on a text one. It is now maxFileSizeKb and maxLength, with a shim routing the old key by type.)

The options / optionTags mechanism is how flags are passed: 'options' => 'ALLOW_HTML USE_TEXTAREA' becomes $this->getPropertyDefinition('optionTags')['ALLOW_HTML'] === true. Use it for boolean switches on your widget; reserve ALLOW_HTML / ALLOW_RAW for the request phase (they relax input sanitizing in the base getDataFromRequest() — only use them for trusted admin-only fields).


Keys read by type generation and platform scope

keymeaning
apiTitle / apiDescriptionThe field's wording for somebody reading a schema instead of the admin form — they become title/description in the generated JSON schemas, the PHP record stubs and the TypeScript types. Both fall back to label/tooltip, so a field whose admin wording already reads correctly to a developer needs neither; give every stored property the pair on new work. Writing guidance: ../technical/com_configbox_property_definition_settings.md §9.
platformsarray of platform names the property APPLIES on (joomla, wordpress, magento2, standalone); absent = all. The property still exists everywhere — stored, readable, writable, one schema for every host. What this changes is presentation: elsewhere it is hidden in the form (isVisible() false, same as invisible) and can never be required (isRequired() false, so platforms overrides required). Emitted as x-configbox-platforms and stated in the field's description.
platformDefaultsmap of platform name → the value a NEW record starts with there, e.g. array('magento2' => 0), overriding default on that host only. The column, its DB-level default and every existing row stay identical everywhere. Use it with platforms when a field is hidden on a host AND has to start at a different value there. Emitted as x-configbox-platform-defaults.
deprecatedtrue marks the field deprecated in the schema (native keyword) and the record stub.

6. Storing in a side table (storeExternally)

If a property's data does not belong in the model's base table (e.g. one-to-many rows, or a value keyed by language/group), set it up for external storage and the base class does the JOIN, the read and the write for you:

$propDefs['spec_sheet'] = array(
'name' => 'spec_sheet',
'type' => 'mytype',
'label' => 'Spec sheet',
'storeExternally' => true,
'foreignTableName' => '#__configbox_product_specsheets', // side table
'foreignTableAlias' => 'specsheet', // alias used in the JOIN
'foreignTableKey' => 'product_id', // FK back to the base table key
);

With this, getJoinsForGetRecord() adds a LEFT JOIN on the side table (:897), getSelectsForGetRecord() selects from it, and the base store() does an upsert of $data->{propertyName} into the side table keyed by the base record's id, with proper NULL handling (:322). getDataKeysForBaseTable() returns array() so the value is not also written to the base table. Override copy()/delete() if the side data needs special cloning/cleanup beyond the single-row default.

Use this when one column on a side table maps to one logical field. For genuine one-to-many grids, look at the childentries type instead.

The full mechanism is ../technical/com_configbox_property_types.md §3 — including which types support it (only column-kind ones; derived and layout types have no base-table column to move), what happens on delete and copy, and the constraints your side table has to satisfy. Two that catch people out: foreignTableKey must carry a UNIQUE index, because the write is an upsert relying on the duplicate-key collision — without it you get a second row per save instead of an update; and the base delete() does remove your side-table row when the record goes, so you do not need an afterDelete() for that.


7. Conditional applicability — applies() and appliesWhen

A property can be shown/validated only when other fields have certain values. Add appliesWhen to the definition and the base applies() (:180) evaluates it:

'appliesWhen' => array(
'pricing_mode' => 'fixed', // applies only when pricing_mode == 'fixed'
'enabled' => array('1', '2'), // ... AND enabled is 1 or 2
'status' => array('!archived'), // ... AND status is not archived
// '*' means "any non-empty value", '!*' means "still empty".
),

When a property does not apply, it is hidden in the form and skipped by required-validation. You normally do not override applies(); configure it through appliesWhen.

Negation is a leading !. Within one key, plain values are alternatives (an IN list) and negated values are exclusions that all have to hold, so array('!a', '!b') reads as "neither a nor b". Mixing them means "one of the positives, and none of the negatives".

Old notes say never to negate — that was true until 2026-08-12. The two evaluators used different sigils (KenedoProperty::applies() read a leading 1, assets/kenedo/kenedo.js a leading !), and PHP only negated single-value lists, so a negated condition behaved one way in the form and the other in validation. Both sides now implement ! with the same semantics, and 1 is no longer a marker — 'enabled' => '1' means "equals 1". See ../technical/com_configbox_property_types.md §1, which also records the one residual difference (a '' should-value against a record value of '0').

A property that is conditionally required must also not appear in an API's flat required list — it is not required for the other cases. See ../technical/com_configbox_property_types.md §1.


8. Adding CSS and JavaScript to a property

Properties can declare their own assets, loaded only for views that actually contain the property and de-duplicated across multiple uses. This works on both full page loads and XHR view injections (admin forms are frequently injected via XHR, not full reloads). The mechanism: the property returns asset references; KenedoView collects them from all its properties, merges them with the view's own, and emits them so the AMD loader (assets/main.js) wires them up.

8.1 Optional CSS — getStyleSheetUrls()

Return an array of full stylesheet URLs (KenedoProperty::getStyleSheetUrls(), :760):

function getStyleSheetUrls() {
return array(
KenedoPlatform::p()->getUrlCustomizationAssets().'/css/properties/colorpicker.css',
);
}

The view runs these through the same optimization pipeline as its own stylesheets (minified-variant lookup + cache busting) and injects them de-duplicated. Put the file under data/customization/assets/css/properties/ (URL via getUrlCustomizationAssets()). Stock property CSS lives under the core assets/css/properties/ dir for reference. Scope every selector to your property's wrapper class — .property-type-colorpicker … — so it cannot leak into other properties.

8.2 JavaScript — getJsInitCallsOnce() / getJsInitCallsEach()

Return arrays of "moduleId::method" AMD strings:

  • getJsInitCallsOnce() (:783) — runs the first time a containing view is shown per page load. Use for document-delegated handlers (cbj(document).on(...)) and one-off setup that must not repeat on re-injection. De-duped globally.
  • getJsInitCallsEach() (:805) — runs every time a containing view is shown, including each XHR injection. The named method receives the injected view (a jQuery collection) as its only argument.
function getJsInitCallsEach() {
return array('cbcustom/properties/colorpicker::initEach');
}

Idempotency matters for …Each: the same DOM can be processed more than once when a parent view is re-injected. Scope your work to the view and guard already-initialized nodes with a marker class:

// data/customization/assets/javascript/properties/colorpicker.js (AMD module 'cbcustom/properties/colorpicker')
define([], function () {
return {
initEach: function (view) {
view.find('.property-type-colorpicker:not(.cb-prop-initialized)')
.addClass('cb-prop-initialized')
.each(function () {
// wire up this instance once
});
}
};
});

The moduleId is free-form — point it at any AMD module id your customization registers (e.g. via appConfig.customPaths). Stock property modules live under kenedo/properties/* and configbox/properties/* for reference. There is no build step/bundler: CBX serves AMD modules directly (RequireJS), so a plain define(...) file is enough.


9. Deploying a custom property (checklist)

A custom property is customization-layer code: it lives under data/customization/ (gitignored, upgrade-safe — it is not overwritten by component updates), and the DB column it maps is delivered by a customization migration.

data/customization/
properties/
colorpicker.php ← the type class (KenedoPropertyColorpicker)
tmpl/
colorpicker.php ← the form widget template
assets/
css/properties/colorpicker.css ← optional CSS (getStyleSheetUrls)
javascript/properties/colorpicker.js ← optional AMD module (getJsInitCalls*)
updates/
<version>.php ← migration: ALTER TABLE … ADD COLUMN color VARCHAR(7) …
model_property_customization/
<model>.php ← (optional) inject the definition into a stock model

Steps:

  1. Write the class at data/customization/properties/<type>.php with class KenedoProperty<Ucfirst(type)> (see §2 for the naming rule — keep <type> lowercase to avoid surprises).
  2. Write the template at data/customization/properties/tmpl/<type>.php (unless the property renders nothing or builds its own HTML in the class).
  3. Add the column via a customization migration (data/customization/updates/<version>.php), guarded with ConfigboxUpdateHelper::tableFieldExists(). Never ALTER TABLE by hand. See technical/com_configbox_migrations.md.
  4. Use the type in a model: either a model you own, or inject the definition into a stock model via data/customization/model_property_customization/<model>.php (the merge point is KenedoModel::getCustomPropertyDefinitions()).
  5. (Optional) Ship CSS/JS under data/customization/assets/… and reference them from getStyleSheetUrls() / getJsInitCalls*().
  6. Verify manually. CBX has zero automated tests — load the admin form, save, check the listing, and (for XHR-loaded forms) confirm the widget works after injection, not just on full reload.

Regenerate the types when your customization changes shapes

Type generation bakes the effective definitions in — your model_property_customization/ files and any models you ship under data/customization/models/ included. After adding, removing or retyping a property, run

php cli/joomla.php configbox:generate-types

and commit the updated generated/ artifacts together with the customization change. configbox:generate-types --check fails while they disagree, so a CI gate catches the forgotten run. Entities and fields your customization adds are marked (x-configbox-origin), so the diff reads as what it is.


10. Conventions & gotchas

  • Override the minimum. Every hook has a working default. A property that just needs a different widget is one template file + a thin class.
  • Escape on output, sanitize on input. SQL here is traditionally hand-built mysqli: getQuoted() backtick-quotes identifiers, getEscaped() escapes values (add the surrounding quotes yourself) — or bind values with setPreparedQuery($sql, $params) and skip manual escaping. In templates use hsc(); in getDataFromRequest() validate/normalize; never interpolate raw request data into SQL.
  • Don't shadow a stock type by accident. A file in data/customization/properties/<stocktype>.php replaces that built-in type install-wide. Use a fresh type name.
  • Type-string casing. Because the class name is ucfirst(strtolower($type)), keep type strings lowercase. CamelCase works only thanks to PHP's case-insensitive class names — don't rely on it in new code.
  • …Each JS must be idempotent. Guard with a marker class; the same DOM can be initialized repeatedly on re-injection.
  • The column is the migration's job, not the property's. A property maps an existing column; the schema change is a versioned, idempotent upgrade script.
  • Match the surrounding style. No namespaces, no PSR-4, static singletons, PHP templates — follow the existing Kenedo idioms rather than modern Joomla/PSR patterns.

See also

  • technical/com_configbox_property_types.mdthe built-in type catalogue: the settings every type accepts, the storage kinds, storeExternally (§3), and the type-selection table. One article per type under technical/property-types/.
  • technical/com_configbox_property_definition_settings.md — the storage keys (dataType, nullable, unique, maxLength) and §7 on declaring storage for a custom type.
  • technical/com_configbox_kenedo_mvc.md — the framework and the model/view loop (§4).
  • technical/com_configbox_mvc_tasks.md — the base tasks, including how copy() deep-clones property side data.
  • technical/com_configbox_migrations.md — delivering the DB column your property maps (core and customization tracks).
  • external/kenedo/classes/KenedoProperty.php — the base class; every overridable hook with its default.
  • external/kenedo/properties/ + …/properties/tmpl/ — ~30 worked examples (string, boolean, note, join, file, groupPrice, rule, …) to copy patterns from; each is documented in technical/property-types/.