Property definition settings — storage, validation and the keys that govern them
- Version
- 4.0 preview
- Updated
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),multiselectandchildentries(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:
$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.
'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 —
0points at no row while looking like it points somewhere - an override — NULL means "no override";
0means "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)
'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-driftcompares your field against avarchar(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
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
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.5did 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/
@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.
$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:
- What it holds, in terms of the record, not the screen.
- The valid values, named, when the set is closed.
`integer`, `decimal`, or an empty valuebeats "you can restrict what is entered". - What it affects — the other field, the endpoint, or the behaviour that changes.
- 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.
unitis 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
'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 fromrequired, 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
'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
'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.