# Property definition settings — storage, validation and the keys that govern them

> A property definition is the array a model's getPropertyDefinitions returns per field. It drives the admin form, the list view, the SQL a read builds, and — …

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

---
A property definition is the array a model's `getPropertyDefinitions()` returns per field. It drives
the admin form, the list view, the SQL a read builds, and — since the storage vocabulary was added —
what the column is and what the generated types say about it.

This documents the **storage** keys and the ones they are confused with. For the form and list keys
(`positionForm`, `positionList`, `listCellWidth`, `canSortBy`, `addSearchBox`, `addDropdownFilter`,
`makeEditLink`, `tooltip`, `invisible`, `appliesWhen`) see `com_configbox_kenedo_model.md`.

---

## 1. The three storage questions

Every property answers three questions. Two have per-definition overrides; one does not.

| Question | Answered by | Overridable per property? |
|---|---|---|
| Is this a column at all? | `KenedoProperty::getStorageKind()` | **No** — it is a fact about the type |
| What column type? | `getDefaultDataType()`, overridden by `'dataType'` | Yes |
| Can it be NULL? | `getDefaultNullable()`, overridden by `'nullable'` | Yes |

### `getStorageKind()` — `column` | `derived` | `layout`

- **`column`** — a real column of the model's own table. The default.
- **`derived`** — appears on records but is not a column here: `translatable` (lives in
  `#__configbox_strings`), `multiselect` and `childentries` (their own tables), `taxclassrates`.
- **`layout`** — form chrome that never reaches a record: `groupstart`, `groupend`, `note`. These
  appear in **no** generated artifact.

It is deliberately not overridable per definition. Where a property's value lives is a property of
the *type*, not of one field's configuration — a `groupstart` is never a column, whatever a
definition says.

---

## 2. `dataType`

The column type in MySQL syntax, so it can drive DDL directly: `'varchar(100)'`,
`'decimal(20,4) unsigned'`, `'int unsigned'`, `'text'`.

Set it **only where the type's default is wrong**:

```php
$propDefs['default_tax_rate'] = array(
    'name'     => 'default_tax_rate',
    'type'     => 'number',
    'dataType' => 'decimal(4,2) unsigned',   // narrower than the type's decimal(20,4)
);
```

Defaults in force:

| Property type | Column |
|---|---|
| base `KenedoProperty`, `string` | `varchar(255)` |
| `dropdown`, `radio` | `varchar(50)` — a choice key is a short token |
| `pseudojoin` | `varchar(100)` — a template folder or connector name |
| `boolean`, `published` | `varchar(1)` |
| `id`, `join`, `calculation`, `countyselect`, `stateselect`, `ordering` | `int unsigned` |
| `number` (`integer` variant) | `int` |
| `number` (decimal / money / percent) | `decimal(20,4)` |
| `datetime` | `datetime` |
| `json`, `rule`, `groupPrice`, `calculationOverride` | `text` |

### Two rules that are not obvious

**255, not wider.** utf8mb4 charges 4 bytes per character, so `varchar(1000)` costs 4000 bytes of
MySQL's 65535-byte row limit. The wide tables exhaust it long before every column is converted —
`#__configbox_questions` alone would need ~132000. This was tried; it fails with "Row size too
large".

**Signedness is not derived from `signed`.** The `signed` key on a `number` governs *form
validation*. Most money columns here are deliberately signed, because discounts, price overrides and
import adjustments go negative. Deriving `unsigned` from that flag mistyped 33 money columns. A
column that really is unsigned says so in its `dataType`.

---

## 3. `nullable`

Whether the column accepts NULL. Default: `false`, except that a definition declaring
`'default' => null` is treated as nullable without needing the key.

```php
'nullable' => true,     // "not set" is a real state for this field
```

### `nullable` is not `required`

They are asked by different parts of the system and answer different questions:

| Key | Governs | Enforced by |
|---|---|---|
| `required` | may the **form** be submitted with this empty? | `check()`, at validation time |
| `nullable` | may the **column** hold NULL? | the database |

A field can be optional in the form and still `NOT NULL` in the database, storing `''`.
`tax-class.id_external` is exactly that. Setting `required` does not make a column NOT NULL, and
making a column NOT NULL does not make a form field required.

### When NULL is the right answer

Prefer nullable where "absent" is a real state and a sentinel would lie:

- an optional foreign key — `0` points at no row while *looking* like it points somewhere
- an override — NULL means "no override"; `0` means "overridden to zero", a different statement
- an optional date — NULL beats a zero date

Prefer NOT NULL with a default for flags and positions: a boolean is on or off, an unsorted row is
position `0`. Those are never genuinely unknown.

**A foreign key with `ON DELETE SET NULL` cannot be NOT NULL.** That is a constraint, not a
preference — the database will write NULL into it.

---

## 4. `unique` and `copySuffix` (string type)

```php
'unique'     => true,                                   // the column carries a UNIQUE index
'copySuffix' => true,                                   // "Autumn Sale" -> "Autumn Sale (1)"
'copySuffix' => function($value, $attempt) { … },       // or your own
```

`unique` makes saving a duplicate a **validation refusal** with a message naming the field, instead
of a raw `Duplicate entry 'EUR' for key 'code'` from the database.

Copying a record with a unique value is then **refused unless** `copySuffix` says how to make the
copy distinct. Refusing is often correct: a currency code is ISO 4217 and `EUR (1)` is not a
currency. Candidates are checked against the table and fitted to `maxLength` by shortening the
*original*, never the suffix — the suffix is the part doing the work.

---

## 5. `maxLength` (was `size`)

`size` meant two unrelated things depending on type: a kilobyte ceiling on a `file` upload, and a
`maxlength` on a text input. It is now `maxFileSizeKb` or `maxLength`. A shim routes an old `size`
by type, so existing customizations keep working — but the shim is the only thing standing between
you and a silently wrong reading.

---

## 6. `default`

The value a new record gets, and — since it is now carried through — the column default in generated
DDL. `'default' => null` additionally marks the property nullable (§3).

Give every boolean a default. Two shipped without one, which is how they ended up as nullable
columns holding neither `'0'` nor `'1'`.

---

## 7. Custom property types must declare their storage

### What this is about

Every property type answers "what column am I?" — and it answers it **in the type's PHP class**, not
in the definition. `KenedoPropertyString` says `varchar(255)`; `KenedoPropertyBoolean` says
`varchar(1)`. A definition's `dataType` only overrides that answer for one field.

If you write your own property type and override neither `getStorageKind()` nor
`getDefaultDataType()`, your type inherits the answer at the bottom of the hierarchy: **"I am a real
column, and I am `varchar(255)`."**

Nothing goes wrong immediately — that is the problem. Your type keeps working, the form renders, the
value stores. What is wrong is everything *describing* it:

- the generated JSON schema and record class say your field is a `varchar(255)` column
- `--schema-drift` compares your field against a `varchar(255)` and reports whatever it finds
- any future DDL generation would create the wrong column

If your type actually stores JSON in a `TEXT` column, or a set of rows in its own table, or nothing
at all (form chrome), then every one of those is wrong — and silently, because inheriting a default
is indistinguishable from choosing it.

### What to do

```php
class KenedoPropertySitecolour extends KenedoProperty {

    public function getStorageKind()      { return 'column'; }   // or 'derived' / 'layout'
    public function getDefaultDataType()  { return 'char(7)'; }  // #rrggbb
    public function getDefaultNullable()  { return true; }       // optional
}
```

**Extending a stock type counts as declaring.** `extends KenedoPropertyString` inherits
`varchar(255)` from a class that *chose* it — that is a real answer, not a fallback. Only a type
extending `KenedoProperty` directly and overriding neither method is reported.

### How you find out

`configbox:generate-types` names them:

```
tax-class   id_external   type "sitecolour" (KenedoPropertySitecolour)
[WARNING] 1 propert(ies) use a custom type that does not declare getStorageKind() or
          getDefaultDataType(), so their generated column type is the inherited default
          (varchar(255)) rather than anything the type actually chose.
```

Advisory, not fatal — a wrong column type in a generated artifact is worth knowing about, but not a
reason to refuse to generate the other 500 properties.

---

## 8. Checking your definitions against the database

```bash
php cli/joomla.php configbox:generate-types --schema-drift
```

Lists every column-backed property whose declared `dataType` or `nullable` disagrees with the live
table, with model, property, property type, table, column and both types.

### The definitions are the source of truth

This is the part worth being unambiguous about, because the tool reads the database and it would be
natural to assume the database wins.

**It does not.** The property definitions describe what the schema *should* be; the database is
merely what it currently *is*. The long-term goal is that the definitions can generate tables and
columns, and that is only meaningful if they are authoritative.

So a drift row is a question — *which side is wrong?* — and it is answered case by case:

- **the definition is wrong** → fix the definition (add or correct `dataType` / `nullable`). This is
  the common case, and it is how the initial 534 rows were mostly resolved.
- **the column is wrong** → fix the column, in a migration. Update `3.6.5` did this for 187 columns
  whose type was an accident of history rather than a decision.

`--schema-drift` never writes. It does not "fix" a definition from the database and it does not
alter a column — it only tells you the two disagree. Deciding which one moves is yours.

### It should read zero

Once resolved, drift is a **regression guard**. A non-zero reading means a definition and its column
have parted company since — someone changed a column without the definition, or a definition without
a migration. Either way something is now describing storage that does not exist as described.

@see com_configbox_property_types.md — the per-type reference: which `type` to choose, the settings
     every type accepts, the three storage kinds, and `storeExternally` (§3). One article per type
     under [`property-types/`](https://docs.configbox.at/docs/4.0-preview/technical/property-types/)
@see com_configbox_type_generation.md — what the definitions generate
@see ../customization/com_configbox_custom_properties.md — declaring storage for a type of your own (§7 here)
@see com_configbox_migrations.md — delivering the column a definition describes

---

## 9. `apiTitle` and `apiDescription` — wording for a developer, not an operator

`label` and `tooltip` are written for a store operator standing in front of a form. `apiTitle` and
`apiDescription` are the same field explained to somebody reading a schema. Both are optional and
both fall back, so a definition whose admin wording already reads correctly to a developer needs
neither.

```php
$propDefs['show_in_overview'] = array(
    'name' => 'show_in_overview',
    'type' => 'boolean',
    'label' => KText::_('Show in Overview'),
    'tooltip' => KText::_('Choose yes to show the question in configuration overview pages.'),
    'apiTitle' => 'Show in summaries',
    'apiDescription' => 'Whether this question appears in cart lines, quotes and order '
        .'confirmations. GET /cart/getCart returns only the questions with this set - read '
        .'getConfiguration for the complete configuration.',
);
```

### Why two sets of words

The two audiences differ in what they can see. An operator has the form in front of them, so
"Choose yes to show the question in configuration overview pages" is complete: *which* question is
obvious, and "overview pages" is a screen they know. A developer has a field name in a JSON schema.
They cannot see which question, they have never seen an overview page, and what they actually need
to know is which endpoint changes behaviour as a result.

Measured across the component before this pair existed: of 612 write fields, **382 carried no prose
at all**, 119 used deictic language (`this`, `here`, `the following`), 82 addressed the reader as
"you", and 30 were phrased as questions. That is good form-writing and poor schema-writing.

### The names

`apiTitle` and `apiDescription` are named after the two members OpenAPI and JSON Schema use for
exactly this — `title` for the short human name, `description` for the prose — so the mapping out is
one to one. `ConfigboxTypeGenerator` reads them through `KenedoProperty::getApiTitle()` and
`getApiDescription()`; nothing else should read them directly.

### How to write them

**Title** — a noun phrase. No question marks, no verbs in the imperative, no "Do you want to…".
`Input type`, not `How do you want to display the question?`.

**Description** — one to four sentences, in this order as far as they apply:

1. **What it holds**, in terms of the record, not the screen.
2. **The valid values**, named, when the set is closed. `` `integer`, `decimal`, or an empty value ``
   beats "you can restrict what is entered".
3. **What it affects** — the other field, the endpoint, or the behaviour that changes.
4. **The gotcha**, if there is one. This is the part that is worth the most and is almost never in
   the tooltip.

Rules that follow from having no screen:

- **No deixis.** Not "this question" or "the setting below" — name the field.
- **No second person.** Not "you can set…" but "sets…".
- **Name related fields in backticks**, so a reader can navigate: `` only used when `prefill_on_init`
  is set ``.
- **Say what it does NOT do** where the name over-promises. `unit` is display only: it does not
  convert or validate anything.
- **Prefer the API's vocabulary.** "Configuration" rather than "the customer's choices"; endpoint
  names rather than screen names.

### What NOT to give API text

Don't paraphrase a tooltip that is already correct for both audiences — the fallback exists so that
the same sentence is not maintained twice.

Five structural names are handled centrally by `KenedoProperty::getStructuralApiDescription()` — `id`,
`ordering`, `published`, `created_at`, `updated_at` — because their meaning is fixed by the framework
rather than by the entity. That list is deliberately tiny: guessing a field's meaning from its name is
how documentation starts lying, so names like `title` and `name`, which mean different things on a
product and a tax class, are excluded on purpose and need explicit text.

### Checking coverage

The API description is generated, so drift shows up as a blank in the schema rather than as an error.
`specs/api/api-description.spec.ts` asserts that **every** field in both generated schemas carries a
description, which is what stops a newly added property from silently arriving undocumented.

## 10. `apiSensitive` — a secret the API accepts but never returns

```php
'apiSensitive' => true,
```

For a field that holds a credential: the store's licence key (`config.product_key`), the Anthropic
API key (`config.ai_api_key`), a payment provider's settings JSON (`paymentprovider.settings`).
Declaring it changes three things, all derived from the one key:

- **Every API read strips the field** — single reads, lists, HTTP and MCP alike. The strip lives in
  the shared projection (`ConfigboxApiRecord::project()` and the listing path), so no surface can
  disagree. A read-scope token can no longer retrieve a secret that a write-scope token stored.
- **The generated read schema marks it `writeOnly: true`** — the JSON Schema member that means
  exactly this — and drops it from `required`, so a real response still validates.
- **The write schema keeps it writable** and says in prose that the value will never come back for
  confirmation.

The field stays on the **server-side record** untouched: `getRecord()` returns it, the engine and
the admin form read it as before. This is an API-surface concept, not encryption at rest.

Read through `KenedoProperty::isApiSensitive()`; nothing else should read the key directly.

## 11. `platforms` — a property that only *applies* on some platforms

```php
'platforms' => array('joomla', 'wordpress'),
```

Names the platforms the property applies on. **Absent means all platforms** — the overwhelmingly
common case.

**It does not decide whether the property exists.** Every property exists on every host: stored,
readable, writable, one schema, one set of migrations. What the key governs is **presentation**,
through two accessors and nothing else:

- `KenedoProperty::isVisible()` is false where it does not apply, so there is no form field and no
  list column;
- `KenedoProperty::isRequired()` is false with it — load-bearing, because a hidden field that still
  demanded a value would refuse a save nobody could satisfy.

Both go through `KenedoProperty::appliesToThisPlatform()`; nothing else should read the key.

**This replaces the per-model `if (KenedoPlatform::getName() == 'magento2') { … }` branches** that
used to sit inside `getPropertyDefinitions()` — declare the scope instead of branching on it. The
`appliesWhen` processor needs no special case: a platform-hidden field carries the same
`invisible-field` class as an `invisible` one, and `kenedo.js` skips invisible fields outright, so a
satisfied `appliesWhen` can never un-hide it.

**It used to be a filter** in `KenedoModel::getProperties()`, and removing that was the point. While
the runtime property set differed per host, the entity API's writable set, the generated schemas and
`cbx_describe_entity` disagreed with each other — describe advertised `baseprice` on Magento and the
create then refused it. One shape everywhere is what keeps those surfaces honest.

Type generation reads definitions rather than the runtime set (`getPropertiesForAllPlatforms()`), so
the **committed artifacts describe every platform at once** and mark the exceptions: read and write
schemas both carry `x-configbox-platforms` plus a sentence of prose, and the PHP record stubs note
`platforms: …` in the field docblock.

## 12. `platformDefaults` — a different starting value on one platform

```php
'platforms'        => array('joomla', 'wordpress', 'standalone'),
'platformDefaults' => array('magento2' => 0),
```

The companion to `platforms`: it names hosts where a **new record starts with something other than
the declared `default`**. It exists because the old per-platform if-blocks did two things at once —
`['invisible'] = true` **and** `['default'] = 0` — and only the first has a presentational meaning.
`product.page_nav_show_buttons`, for instance, declares `'default' => 2` ("use the global setting")
but must start **off** on Magento, which owns page navigation itself.

**It changes the value a fresh record is born with, and nothing else.** The column, its DB-level
default and every existing row are identical on every platform, because the schema is one set of
migrations for all of them.

Resolved inside `KenedoProperty::getPropertyDefinition('default')`, so every caller that builds a
blank record — `initData()`, `setDeclaredDefault()`, the strict-mode INSERT fill, the entity API and
the test-automation seeders — gets the right answer without each having to know the key exists.
Code that wants the platform-neutral declaration (type generation, API examples) reads
`$definition['default']` from the array directly and is unaffected; the artifacts therefore keep
showing the declared `default` and add `x-configbox-platform-defaults` beside it.

Only meaningful on hosts where the property is hidden — if a field is visible and editable, give it
one `default` and let the admin change it.
