# Type generation — record classes and JSON schemas

> > Scope: the generator behind configbox:generate-types — what it emits, from what, and why · > Last reviewed: 2026-08-02

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/technical/type_generation. Last updated 2026-08-12.

---
> **Scope:** the generator behind `configbox:generate-types` — what it emits, from what, and why ·
> **Last reviewed:** 2026-08-02

**Who this is for:** anyone adding or changing model properties (core or customization) and anyone
consuming the generated records, schemas or TypeScript types.

CBX generates, from each model's property definitions, one **PHP record class** and one
**JSON schema** per entity. The point is editor and tooling support: `KenedoModel::getRecord()`
returns a plain `stdClass`, so without this nothing knows what fields a product or a question record
actually has.

Both artifacts are produced in a **single pass** over the properties, so they cannot drift apart —
the generator builds one descriptor per entity and the two renderers only format it.

> **Generated files are never hand-edited.** Every run rewrites them and deletes anything no longer
> generated. If you need per-entity logic, it belongs in the model, not in a record class.

---

## 1. Running it

```bash
php cli/joomla.php configbox:generate-types                  # regenerate the committed base set
php cli/joomla.php configbox:generate-types --check          # verify only; non-zero on drift (CI)
php cli/joomla.php configbox:generate-types --schema-drift   # diagnostic: definitions vs. real columns
```

### Directories that must be writable

| Path | When | Committed? |
|---|---|---|
| `components/com_configbox/generated/records/{read,write}/` | at generation time (dev/CI) | yes |
| `components/com_configbox/generated/schemas/read/` | at generation time (dev/CI) | yes |

A production install never regenerates: `generated/` ships in the package and is read-only code
there. `--check` only compares, so it works on a read-only tree.

The command creates them if they are missing and fails with an explicit message if it cannot write.
It never writes outside them.

---

## Where a field's words come from

Every generated artifact describes a field with the **same** two strings, so a stub, a schema and a
TypeScript declaration cannot say three different things:

| Property definition | Used for |
|---|---|
| `apiTitle` → falls back to `label` | The short name |
| `apiDescription` → falls back to `tooltip` | The prose |

They reach four artifacts:

- `generated/schemas/read/*.json` and `write/*.json` — the `description` of each field
- `generated/records/read/*.php` — the `@property` line, **meaning first, DDL last in brackets**, because
  that line is what an IDE shows on hover and is read far more often than any schema
- `generated/js/records.d.ts` — the doc comment above each member

Five structural names — `id`, `ordering`, `published`, `created_at`, `updated_at` — are described
centrally by `KenedoProperty::getStructuralApiDescription()`, because their meaning is fixed by the
framework rather than by the entity. Nothing else is guessed from a name.

**So: a property added without `apiTitle`/`apiDescription` and without a useful `tooltip` documents
itself as a bare DDL type in four places at once.** `specs/api/api-description.spec.ts` fails when
any field in either schema has no description, which is what stops that reaching a release.


## 2. One artifact set, committed, customization baked in

Generated artifacts are **shipped code**: changing what they say is a codebase change, made in a
repository and reviewed like any other. The set is built from the **effective definitions** —
`getProperties()`, with `model_property_customization/` merged in, and whole customization models
under `data/customization/models/` taking part exactly like stock ones (they opt in the same way,
by returning a name from `getEntityName()`; a model that uses no KenedoProperties never opts in and
is untouched). Artifacts describing definitions no install runs would describe nothing.

**If you develop a customization: re-run the generator after changing shapes, and commit the
updated artifacts with the customization.** `--check` is the reminder — it fails the moment the
artifacts and the effective definitions disagree, naming the command to run. On a stock checkout
effective equals stock, so the component repo's CI gate is unaffected.

Stock and customization are distinguishable in the output where that is a fact rather than a
judgement: an entity from a customization model carries `x-configbox.origin: "customization"`, a
field a customization **adds** carries `x-configbox-origin` (and `customization` in the record
stub's storage note). A field a customization merely *alters* — say, replacing a dropdown's
`choices` — is not marked; presence is a fact, diffing definitions is judgement.

Nothing is ever generated into the install's writable `data/` tree. (An earlier iteration wrote a
per-install "overlay" set under `data/generated/`; it was removed — per-install artifacts nobody
ships are per-install artifacts nobody trusts, and writing PHP into a web-writable tree is a risk
with no matching reward.)

## 2a. What each field says — the metadata vocabulary

Beyond the type, every schema field carries the facts the definitions know, native keywords first:

| key | when | meaning |
|---|---|---|
| `readOnly` | `id` properties | never sent on a write |
| `deprecated` | definition declares `'deprecated' => true` | slated for removal; the record stub notes it too |
| `x-configbox-property-type` | always | the Kenedo property type (`join`, `translatable`, …) |
| `x-configbox-logical-type` | always | what the value MEANS — `integer`, `number`, `boolean`, `enum`, `datetime`, `json` or `string` — as opposed to `type`, which stays honest about mysqli handing everything over as a string. The record stubs note it (`logical: boolean`) only where the column type masks it. |
| `x-configbox-references` | `join`/`calculation` | the target as `{entity, model, property, schema}` — entity/schema are null when the target model does not take part in generation |
| `x-configbox-langtype` | translatables | the field's slot in `#__configbox_strings` |
| `x-configbox-platforms` | definition declares `'platforms'` | where the field exists; see below |
| `x-configbox-origin` | customization-added fields | see §2 |
| `x-configbox-datatype` | column-backed fields | the column DDL (`int unsigned NOT NULL`) — structured, because DDL generation will one day read it |
| `x-configbox-storage` | derived fields only | `"derived"` — the field is on the record but not a column (`column` is the unstated norm) |
| `x-configbox-derived-from` | per-language keys, display values, href/path keys, join-selected keys | which field this one derives from — lets a consumer regroup `title_en_GB` under `title` |
| `x-configbox-language` | per-language keys | the language tag |
| `x-configbox-applies-when` | conditionally-applying fields | the condition, uniform per field: `{"any": true}` or an `"in"` and/or `"not"` list, ANDed across fields. Faithful to `KenedoProperty::applies()` — plain values are alternatives, values with a leading `!` are exclusions that all have to hold, `'*'`/`'!*'` mean non-empty/empty. Also on the write side's per-language keys; the stubs carry it as prose (`applies when question_type in [calendar]`). |

**`$comment` is not used for facts.** Per JSON Schema 2020-12 it addresses schema *maintainers*,
may be stripped by tooling and must not be relied on. The one `$comment` in the artifacts is the
write schemas' rule placeholder — a genuine note about the schema itself. The human-prose storage
note lives in the record stubs, where humans are the audience.

## 2a-2. Read schemas and WRITE schemas

Each entity now generates **three artifacts**: the record stub, the read schema
(`schemas/read/<entity>.json`) and the **write schema** (`schemas/write/<entity>.json`).

**Read** describes a record as `getRecord()` returns it — numeric columns as real ints and floats
(`KenedoModel::castRecordValues()` converts on read; everything else, including `'1'`/`'0'`
booleans, stays a string and is compared loosely as ever), and since 2026-07-27 the WHOLE record: the per-language keys every translatable carries (`title_en_GB`, …)
and the `<name>_display_value` each reference carries, the `<name>_href`/`<name>_path` keys every
image and file property emits (`''` when no file is set — the property emits them unconditionally
since 2026-07-28, plus per-mutation variants where a definition declares mutations), a multiselect's
value as the ARRAY it really is (the assigned ids as strings), and every FURTHER key a property's
selects put on the record — a join's `joinedby_*` columns (recursively), `taxclassrates`' one rate
per tax class — parsed out of the live `getSelectsForGetRecord()` fragments. Properties that never
reach a record at all (`childentries`) contribute no key. As of the same date a live record and its
read schema agree at `additionalProperties: false` strictness.

**Translatables are the exception, and deliberately so.** A schema describes the WIRE shape, and on
the wire a translatable is one object — `{"en_GB": "…", "de_DE": "…"}` — with no flat per-language
keys, because `ConfigboxApiRecord::project()` drops them. It is also **language-agnostic**: the keys
are constrained by `propertyNames` pattern and no tag is named. The active set belongs to the
install, so a committed artifact naming `de_DE` documents whichever store generated it — a customer
on fr-FR was handed a field that does not exist for them and denied the one that does.
`ConfigboxOpenApiHelper::nameActiveLanguages()` fills the pattern in when it assembles the served
document, which is per request and legitimately knows. The pattern stays, so a language added after
an export is not rejected.

The **record stubs and the TypeScript types still carry the flat keys**, because they describe the
PHP object rather than the response, and `title_en_GB` is genuinely on it. Those two remain baked
from the install's languages — **add a language, re-run the generator**; `--check` reminds.

**Write** describes what a caller may send to store one: logical JSON types (a reference is an
`integer`), per-language keys for translatables, `required` meaning required-on-creation and
honouring `appliesWhen`, and the field kinds that cannot be written this way — file uploads,
images, child entries — named in `x-configbox.notWritable` instead of pretended. Rule fields are a
placeholder; runtime consumers splice the live authoring vocabulary.

**One producer.** The MCP server's tool schemas are this same `buildWriteSchema()` output with two
MCP-specific decorations (the live rule vocabulary, the `cbx_list_entities` guidance sentence), and
a future HTTP API validates requests against the same document. The read/write pair is what an
OpenAPI description composes from.

## 2b. Platform-limited properties — `'platforms'`

A property that exists only on some platforms declares it:

```php
'platforms' => array('joomla', 'standalone', 'wordpress'),   // i.e. not on magento2
```

Absent means every platform. `KenedoModel::getProperties()` enforces the declaration at runtime —
one filter, replacing per-model `if (KenedoPlatform::getName() …)` branches around definitions —
while generation reads `getPropertiesForAllPlatforms()` and emits the **union**, so one committed
artifact set is correct on every platform and `--check` agrees wherever it runs. Form-only
conditionals (an `invisible` flag, a label swap) stay imperative: they are cosmetics, not shape.

---

## 3. Naming — entities, not model files

Each model declares its own entity name:

```php
class ConfigboxModelTaxClasses extends KenedoModel {

	function getEntityName() {
		return 'tax-class';
	}
```

That name is the entity's **public identity**: it names the file, the record class
(`ConfigboxRecordTaxClass`) and the schema's `$id` URL. It deliberately does not follow the model's
file name, because those carry historical baggage — an `admin` prefix reflecting which side of the
app first needed the model, a plural, and in places pre-CB4 vocabulary. `tax-class` is the entity;
`admintaxclasses` is merely where its model lives.

Nothing is lost by not naming files after the model: every schema carries the real coordinates in
`x-configbox`.

```json
"x-configbox": {
    "entity": "tax-class",
    "model": "ConfigboxModelTaxClasses",
    "modelBase": "admintaxclasses",
    "primaryKey": "id",
    "recordClass": "ConfigboxRecordTaxClass",
    "table": "#__configbox_tax_classes"
}
```

Treat a name as published: renaming one changes a class name and a schema URL.

**Returning `''` opts a model out.** Exactly one model still does:
`ConfigboxModelCustomerGroups` (`models/customergroups.php:13-15`), because it assembles group
records with per-group pricing of its own — it overrides `getRecord()`/`getRecords()`, so its
property definitions do not describe the records it returns. The other two former opt-outs were
re-examined and opted **in**: `ConfigboxModelConfig` generates as **`settings`**
(`models/config.php:18-20`) and `ConfigboxModelShopData` as **`store`** (`models/shopdata.php:17-19`)
— both are singletons whose records *are* the property-driven ones (their overrides only add a
legacy key or call `parent`), so "doesn't follow the definitions" was never true of them; create and
delete are withheld via `getApiOperations()` and the secret-bearing settings fields are
`apiSensitive` instead. `generated/records/read/` currently holds **45** entities.

---

## 4. The property vocabulary

Type generation reads three things off each property, all resolved by `KenedoProperty`.

### 4.1 Storage kind — `getStorageKind()`

Declared by the property class, because it is a fact about the property type:

| Kind | Meaning | In the artifacts? |
|---|---|---|
| `column` | a real column of the model's own table | yes |
| `derived` | on the record but not a column — joins, translatable strings, child entries | yes |
| `layout` | form chrome only, never on a record — `groupstart`, `groupend`, `note` | **no** |

### 4.2 Column type and nullability — `dataType` / `nullable`

Each property class carries a **default** (`getDefaultDataType()` / `getDefaultNullable()`); a
property definition only states `dataType` or `nullable` where the default is wrong.

The defaults in force:

| Property type | Column |
|---|---|
| `string` and the base default | `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)` |
| `number` (`integer` variant) | `int` |
| `number` (decimal/money/percent) | `decimal(20,4)` |
| `ordering` | `int unsigned` |
| `id`, `join`, `calculation` | `int unsigned` |
| `datetime` | `datetime`, nullable |
| `groupPrice`, `calculationOverride` | `text` |

**Why 255 and not wider.** utf8mb4 charges 4 bytes per character, so a `varchar(1000)` column costs
4000 bytes of MySQL's 65535-byte row limit. The wide tables exhaust that long before every column is
converted — `#__configbox_questions` alone would need about 132000 bytes. 255 fits every table with
room to spare.

**Signedness is not derived from `signed`.** That flag governs form validation; most money columns
here are deliberately signed, because discounts, price overrides and import adjustments go negative.
A column that really is unsigned says so with an explicit `dataType`.

A property definition overrides a default like this:

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

A property whose definition declares `'default' => null` is treated as nullable without needing the
key; everything else defaults to NOT NULL.

`nullable` is **not** the existing `required` key. `required` governs form validation; `nullable`
describes the column. A field can be optional in the form and still `NOT NULL` in the database —
`tax-class.id_external` is exactly that, form-optional and `NOT NULL`, storing `''`.

The intent is that these definitions become authoritative for the schema, so that table and column
generation can eventually be driven from them.

### 4.3 Finding the overrides worth writing

`--schema-drift` compares every column-backed property against the live table and lists the
disagreements. That list *is* the annotation backlog — you do not have to guess which of the ~600
properties need an override.

```
ENTITY       FIELD             ISSUE      DECLARED        ACTUAL
tax-class    default_tax_rate  dataType   decimal(10,2)   decimal(4,2) unsigned
tax-class    id_external       dataType   varchar(255)    varchar(100)
```

It is a **diagnostic only**. The database is never the source of truth and nothing is rewritten.

Migration `3.6.5` closed the gap in the other direction, normalising 187 columns whose type was an
accident of history: plain text unified at `varchar(255)`, dropdowns at `varchar(50)`, booleans moved
off `int` to `varchar(1)`, the price-override blobs to `TEXT`, and three stray signed `ordering`
columns to `int unsigned`. It deliberately leaves indexed columns alone — `varchar(255)` in utf8mb4 is
already 1020 bytes and widening an indexed column risks InnoDB's 3072-byte key limit — so those carry
an explicit `dataType` instead.

---

## 5. What the generated types say — and what they don't

Records come from mysqli via `loadObject()`, which hands every value over as a string — but they no
longer stay that way: **`KenedoModel::castRecordValues()` casts numeric columns to real ints and
floats on read** (`KenedoModel.php:1120`, run inside `getRecord()`/`getRecords()`), so a record's
`id` is an `int` and a rate is a `float` by the time any caller sees it. `'1'`/`'0'` **booleans
deliberately stay strings** — the codebase compares them loosely everywhere, and casting them would
turn `$record->published == '1'` sites into subtle behavior changes for zero gain. The record
classes document exactly that reality, with the column type noted alongside — the committed
`generated/records/read/tax-class.php:24-25`:

```php
/**
 * @property float  $default_tax_rate Default rate — Rate applied where no zone-specific rate matches, as a percentage. [decimal(4,2) unsigned NOT NULL; form-required]
 * @property int    $id               ID — Primary key. Assigned on create; send it only in the URL of a read, update or delete. [int unsigned NOT NULL]
 */
class ConfigboxRecordTaxClass extends stdClass {}
```

Two consequences worth knowing:

- **The record classes are IDE stubs and are never instantiated.** `getRecord()` really does return
  a `stdClass`. They exist so editors and static analysis have a shape to resolve against.
- **`dataType` describes the column, not the PHP value.** The stub's `int`/`float`/`string` is the
  runtime type after casting; the bracketed DDL is the column. `dataType` drives the schema and,
  later, DDL.

**Translatable fields** are stored in the EAV table `#__configbox_strings`, not as columns, and at
runtime a record carries the bare key plus one per active language (`title`, `title_en_GB`,
`title_de_DE`). Since the active-language list is per-install, the committed base artifacts document
only the bare key — per-language keys depend on the install's active languages, which committed
artifacts cannot know.

---

## 5a. Custom property types must declare their storage

A property type that does not override `getStorageKind()` / `getDefaultDataType()` inherits the base
answers: a real column, `varchar(255)`. For the stock types that is a considered default. For a type
added under `data/customization/properties/` it is a **guess**, and the generated schema will then
describe a column the type may not use at all.

Generation reports those rather than assuming:

```
tax-class   id_external   type "sitecolour" (KenedoPropertySitecolour)
[WARNING] 1 propert(ies) use a custom type that does not declare getStorageKind() or
          getDefaultDataType() …
```

Inheriting from a stock type (`extends KenedoPropertyString`) counts as declared — that is a real
choice, not a fallback. Only types extending `KenedoProperty` directly and overriding neither are
reported.

---

## 6. Determinism

`--check` is only meaningful if regeneration is byte-stable, so:

- fields are emitted **alphabetically**, not in form order — re-ordering a form is not a diff here;
- JSON keys are sorted;
- **no timestamps**, host names or paths from the generating machine appear in the output.

The generator purges the CBX cache once per run before reading definitions. Property labels
are translated strings and translations are cached, so a stale cache would change a schema's
`description` with no definition having changed — and `--check` would report drift that does not
exist. A CI gate that fails for that reason is worse than no gate, so the generator guarantees its
own inputs rather than documenting a step someone has to remember.

`--check` reports three kinds of drift — `missing`, `out of date`, `orphaned` — and exits non-zero on
any of them, so CI can fail a build whose definitions changed without regeneration.

Regeneration is a **full rebuild**, not an additive patch: files no longer generated are deleted, so
a removed field or a renamed entity actually disappears.

---

## 7. IDE resolution

Two hops are needed for an editor to resolve `$model->getRecord($id)->title`:

1. `KenedoModel::getModel('ConfigboxModelTaxClasses')` → the model class. Already handled by
   `helpers/php_storm_meta/.phpstorm.meta.php`.
2. the model → its record class. `KenedoModel::getRecord()` is declared as returning a plain object,
   and a `.phpstorm.meta.php` map cannot override a return type based on the *receiver* (it maps on
   argument values, and the argument here is a record id). So the concrete type has to be stated on
   the model itself:

```php
/**
 * @method ConfigboxRecordTaxClass|null getRecord(int $id, string $languageTag = '')
 * @method ConfigboxRecordTaxClass[]|int getRecords(array $filters = array(), ...)
 */
class ConfigboxModelTaxClasses extends KenedoModel {
```

`ConfigboxModelTaxClasses` carries this as a sample. Whether the generator maintains these
annotations for all 45 generated models is still open.

---

## 8. Where the code lives

| Piece | Path | Repo |
|---|---|---|
| Generator | `components/com_configbox/helpers/typegen/ConfigboxTypeGenerator.php` | submodule |
| CLI core | `components/com_configbox/helpers/cli.php` (`generateTypes`, `checkTypes`, `reportTypeSchemaDrift`) | submodule |
| Joomla command | `administrator/components/com_configbox/src/Console/GenerateTypesCommand.php` | outer repo |

The generator is platform-agnostic and lives in the shared component, so the WordPress and Magento
builds can drive it through their own thin wrappers. Only the console command is Joomla-specific.

---

## See also

- `com_configbox_cli_commands.md` — the CLI suite, §1.8
- `com_configbox_property_types.md` — **the input to all of this**: what each property type declares
  about itself (`getStorageKind()`, `getDefaultDataType()`, `getValueSet()`), and why a `derived` or
  `layout` type contributes no column. One article per type under `property-types/`.
- `com_configbox_property_definition_settings.md` — `dataType`, `nullable`, `unique`, `maxLength`, and
  the schema-drift check (§8)
- `com_configbox_kenedo_model.md` — models, properties and `getRecord()`
- `docs/customization/com_configbox_extending_stock_models.md` — the customization layer
