Type generation — record classes and JSON schemas
- Version
- 3.x
- Updated
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
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/*.jsonandwrite/*.json— thedescriptionof each fieldgenerated/records/read/*.php— the@propertyline, meaning first, DDL last in brackets, because that line is what an IDE shows on hover and is read far more often than any schemagenerated/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:
'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:
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.
"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:
$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:
/**
* @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 astdClass. They exist so editors and static analysis have a shape to resolve against. dataTypedescribes the column, not the PHP value. The stub'sint/float/stringis the runtime type after casting; the bracketed DDL is the column.dataTypedrives 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:
KenedoModel::getModel('ConfigboxModelTaxClasses')→ the model class. Already handled byhelpers/php_storm_meta/.phpstorm.meta.php.- the model → its record class.
KenedoModel::getRecord()is declared as returning a plain object, and a.phpstorm.meta.phpmap 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:
/**
* @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.8com_configbox_property_types.md— the input to all of this: what each property type declares about itself (getStorageKind(),getDefaultDataType(),getValueSet()), and why aderivedorlayouttype contributes no column. One article per type underproperty-types/.com_configbox_property_definition_settings.md—dataType,nullable,unique,maxLength, and the schema-drift check (§8)com_configbox_kenedo_model.md— models, properties andgetRecord()docs/customization/com_configbox_extending_stock_models.md— the customization layer