Skip to main content
Version: 3.x

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

Version
3.x
Updated
View markdown

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.

QuestionAnswered byOverridable 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:

$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 typeColumn
base KenedoProperty, stringvarchar(255)
dropdown, radiovarchar(50) — a choice key is a short token
pseudojoinvarchar(100) — a template folder or connector name
boolean, publishedvarchar(1)
id, join, calculation, countyselect, stateselect, orderingint unsigned
number (integer variant)int
number (decimal / money / percent)decimal(20,4)
datetimedatetime
json, rule, groupPrice, calculationOverridetext

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:

KeyGovernsEnforced by
requiredmay the form be submitted with this empty?check(), at validation time
nullablemay 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)

'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

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

  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

'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

'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.