# Creating Custom Kenedo Properties

> 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, …

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/customization/custom_properties. Last updated 2026-08-17.

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

```php
// 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'`:

| Concern | Looked up as | Search order (first match wins) |
|---|---|---|
| **Class file** | `colorpicker.php` (`strtolower(type).'.php'`) | `data/customization/properties/` → `external/kenedo/properties/` |
| **Class name** | `KenedoPropertyColorpicker` (`'KenedoProperty'.ucfirst(strtolower(type))`) | must be defined in that file |
| **Form template** | `colorpicker.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 `KenedoPropertyColorpicker` — *not*
   `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 (`listing`→`positionList`,
  `listinglink`→`makeEditLink`, …) — 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 — see `note.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 `require`s 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:

```php
// 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](https://docs.configbox.at/docs/4.0-preview/migration-to-cb4/exceptions-and-validation)

Then storage runs:

4. **`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.

5. **`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
<?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
<?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:

```php
$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()`.)

| Key | Effect |
|---|---|
| `name` | **Required.** Column / request key / data key. |
| `type` | **Required.** The property type (resolves to your class + template). |
| `label` | Form label and default list header. |
| `labelList` | List header override (when different from the form label). |
| `tooltip` | Adds a tooltip to the label. |
| `default` | Default value for new records (read by templates). |
| `required` | Enforced by the base `check()` (respects `applies()`). |
| `invisible` | `true` adds the `invisible-field` class (hidden but present). |
| `hideAdminLabel` | `true` suppresses the label row (`doesShowAdminLabel()`). |
| `positionForm` | Sort order in the edit form. |
| `positionList` | Sort order as a list column; **omit to keep the field out of listings**. |
| `listCellWidth` | List column width. |
| `canSortBy` | Make the list column sortable. |
| `addSearchBox` | Add a search box for the column. |
| `addDropdownFilter` | Add a dropdown filter (powered by `getPossibleFilterValues()`). |
| `makeEditLink` | Wrap the list cell in the row's edit link. |
| `appliesWhen` | Conditional applicability (§7). |
| `options` | Space-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

| key | meaning |
|---|---|
| `apiTitle` / `apiDescription` | The 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. |
| `platforms` | array 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. |
| `platformDefaults` | map 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`. |
| `deprecated` | `true` 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:

```php
$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`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/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:

```php
'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`):

```php
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.

```php
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:

```js
// 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.md` — **the 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/`.
