Skip to main content
Version: 3.x

Type generation — record classes and JSON schemas

Version
3.x
Updated
View markdown

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

PathWhenCommitted?
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 definitionUsed for
apiTitle → falls back to labelThe short name
apiDescription → falls back to tooltipThe 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 definitionsgetProperties(), 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:

keywhenmeaning
readOnlyid propertiesnever sent on a write
deprecateddefinition declares 'deprecated' => trueslated for removal; the record stub notes it too
x-configbox-property-typealwaysthe Kenedo property type (join, translatable, …)
x-configbox-logical-typealwayswhat 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-referencesjoin/calculationthe target as {entity, model, property, schema} — entity/schema are null when the target model does not take part in generation
x-configbox-langtypetranslatablesthe field's slot in #__configbox_strings
x-configbox-platformsdefinition declares 'platforms'where the field exists; see below
x-configbox-origincustomization-added fieldssee §2
x-configbox-datatypecolumn-backed fieldsthe column DDL (int unsigned NOT NULL) — structured, because DDL generation will one day read it
x-configbox-storagederived fields only"derived" — the field is on the record but not a column (column is the unstated norm)
x-configbox-derived-fromper-language keys, display values, href/path keys, join-selected keyswhich field this one derives from — lets a consumer regroup title_en_GB under title
x-configbox-languageper-language keysthe language tag
x-configbox-applies-whenconditionally-applying fieldsthe 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:

KindMeaningIn the artifacts?
columna real column of the model's own tableyes
derivedon the record but not a column — joins, translatable strings, child entriesyes
layoutform chrome only, never on a record — groupstart, groupend, noteno

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 typeColumn
string and the base defaultvarchar(255)
dropdown, radiovarchar(50) — a choice key is a short token
pseudojoinvarchar(100) — a template folder or connector name
boolean, publishedvarchar(1)
number (integer variant)int
number (decimal/money/percent)decimal(20,4)
orderingint unsigned
id, join, calculationint unsigned
datetimedatetime, nullable
groupPrice, calculationOverridetext

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 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:
/**
* @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

PiecePathRepo
Generatorcomponents/com_configbox/helpers/typegen/ConfigboxTypeGenerator.phpsubmodule
CLI corecomponents/com_configbox/helpers/cli.php (generateTypes, checkTypes, reportTypeSchemaDrift)submodule
Joomla commandadministrator/components/com_configbox/src/Console/GenerateTypesCommand.phpouter 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.mdthe 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.mddataType, 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