Kenedo property types — overview
- Version
- 4.0 preview
- Updated
A property type is the class that decides how one field behaves: how it renders, how it reads
and writes, what column it is, and what it validates. The type is chosen by 'type' => '…' in a
property definition; everything else in that definition configures it.
This is the overview: the settings every type accepts, the mechanisms that are shared across
types, and an index of the types. Each type's own settings are in its own article under
property-types/.
Where this fits
| You want | Read |
|---|---|
| Which type to use, and what it does | this document and the per-type articles |
The storage keys — dataType, nullable, unique, maxLength | com_configbox_property_definition_settings.md |
| How properties drive a model's CRUD | com_configbox_kenedo_model.md §3, §7.3 |
| Where properties sit in the framework | com_configbox_kenedo_mvc.md §4 |
| To write a new type | ../customization/com_configbox_custom_properties.md — the full guide, with a worked example |
| To add a field to a stock model | ../customization/com_configbox_extending_stock_models.md |
| What the definitions generate | com_configbox_type_generation.md · com_configbox_mcp_server.md |
1. Settings every type accepts
Identity and labelling
| Setting | Meaning |
|---|---|
name | Required. The property name. Also the record key and, by default, the column name |
label | Shown next to the field and used in validation messages |
labelList | Column heading in the list view, when it should differ from label |
tooltip | Help text on the form |
hideAdminLabel | Render the field without its label |
apiTitle | The field's short name for an API consumer. Falls back to label |
apiDescription | The field's prose for an API consumer. Falls back to tooltip |
Adding or changing a property? Write
apiTitleandapiDescriptiontoo.labelandtooltipare written for an operator looking at the form — they assume the surrounding screen and are often phrased as questions. The API pair is the same field explained to somebody reading a schema with no screen in front of them, and it feeds four generated artifacts: both JSON Schemas, the PHP record stub's@propertyline (what your IDE shows on hover) and the TypeScript declarations. A property added without them documents itself as a bare DDL type. See §9 of the property-definition settings for how to write them.
Placement
| Setting | Meaning |
|---|---|
positionForm | Sort order in the edit form. Properties are ordered by this, not by declaration order |
positionList | Sort order in the list view. Absent means the field is not in the list at all |
listCellWidth | CSS width for the list column, e.g. '90px' |
Behaviour in the list
| Setting | Meaning |
|---|---|
canSortBy | Column header becomes a sort control |
addSearchBox | Field participates in the list's search |
addDropdownFilter | Field gets a filter dropdown above the list |
makeEditLink | Cell links to the record's edit form. Needs component and controller |
component / controller | Where makeEditLink points |
Validation and visibility
| Setting | Meaning |
|---|---|
required | The form may not be submitted with this empty. Not the same as nullable |
default | Value a new record gets; also the column default in generated DDL |
invisible | Field is rendered but hidden — use for values set programmatically that must still persist |
appliesWhen | Conditional relevance — see below |
platforms | Array naming the platforms the property exists on (array('joomla', 'wordpress')). Absent means all platforms. The one platform filter — getProperties() drops excluded properties at runtime; the column still exists everywhere and store() fills its declared default on INSERT. See §11 of the settings doc |
options | Space-separated flags, e.g. 'ALLOW_RAW USE_TEXTAREA' — see below |
invisible is the key that works. 'visible' => false is read by nothing — it was written on 28
properties and silently did nothing until it was corrected. If you want a field to persist without
appearing on the form, 'invisible' => true is the one.
options and optionTags
options is one space-separated string. The constructor splits it into optionTags, a map that
property classes and templates read with isset($optionTags['SOME_FLAG']). You write options;
everything downstream reads optionTags. There is no central registry of flags — each is read by
whichever class cares, so a misspelled flag is silently inert. The ones actually read:
| Flag | Read by | Effect |
|---|---|---|
ALLOW_RAW | base getDataFromRequest() | Take the POSTed value unfiltered |
ALLOW_HTML | base getDataFromRequest() | Filter as HTML rather than stripping it |
USE_TEXTAREA | string template | Render a <textarea> instead of an <input> |
USE_HTMLEDITOR | string / translatable templates | Render the platform's WYSIWYG editor |
NO_SAVE_FILENAME | image, file | Opt out of writing the filename to the column. Rarely wanted — see below |
FILENAME_TO_RECORD_ID | image, file | Name the stored file after the record id |
NODELETEFILE | image, file | Suppress the "delete file at save" checkbox |
Three flags — PRESERVE_EXT, NOFILTERSAPPLY and SKIPDEFAULTFIELD — were carried by 32 shipped
definitions while being read by nothing, in the engine or anywhere else. They have been removed.
If you meet one in a customization it is inert: delete it. A flag that does nothing is worse than no
flag, because the next reader assumes it does something and preserves it.
Persisting the uploaded filename to the property's column is the default. It used to be opt-in,
via a SAVE_FILENAME tag: a definition that forgot it uploaded the file to the data folder and never
updated the column, so the record pointed at nothing, the form came back empty, and nothing anywhere
reported an error. All ten shipped definitions carried the tag, which is the clearest evidence it was
not really optional. The tag is gone; NO_SAVE_FILENAME exists for the rare property that manages its
own column.
appliesWhen
Makes a property relevant only for certain values of another:
'appliesWhen' => array('question_type' => array('calendar')),
The property is then skipped by validation, and by anything asking whether it applies, unless
question_type is one of the listed values. This is why a conditionally-required field must not
appear in an API's flat required list: it is not required for the other cases.
A bare string is accepted and wrapped, so 'appliesWhen' => array('visualization_type' => 'composite')
is the same as passing a one-element array. '*' in the value list means "any value at all, as long
as the other property is not empty" — useful for "this applies once that one has been chosen".
Negation — a leading !. A should-value written '!archived' means "is not archived":
'appliesWhen' => array('status' => '!archived'), // status is anything but archived
'appliesWhen' => array('status' => array('!archived', '!draft')), // neither archived nor draft
'appliesWhen' => array('parent_id' => '!*'), // applies while parent_id is empty
Within one key, plain values are alternatives (an IN list) and negated values are exclusions
that all have to hold — so array('!a', '!b') is "neither a nor b", not "not a, or not b".
Mixing the two means "one of the positives, and none of the negatives". '!*' is the inverse of
'*': it applies while the other property is still empty. Conditions across different keys are
ANDed, as before.
History — this used to be a trap, and old notes still say so. Until 2026-08-12 the two evaluators used different sigils: PHP (
KenedoProperty::applies()) read a leading1as "is not" while the browser (assets/kenedo/kenedo.js) read a leading!, and PHP only negated when the value list had exactly one entry. A negated condition therefore behaved one way in the form and the other way in validation. Both sides now implement the!semantics above, and1is no longer a marker of any kind —'enabled' => '1'means "equals 1", which is what it always looked like. Nothing in the shipped definitions used the old marker deliberately, so no behaviour changed when it was corrected.
The '*' wildcard is safe in both forms — both sides implement it, and both read it as "any
non-empty value".
One known residual difference. PHP derives the tested value with
empty(), so a record value of'0'arrives asnulland matches a''should-value; the browser compares the raw string, where'0'does not equal''. A condition written'field' => ''therefore applies server-side but not client-side when the value is'0'. Nothing ships such a condition, and it predates the negation fix — but do not write''or'0'as a should-value.
Storage — see the settings doc
dataType, nullable, unique, copySuffix, maxLength, maxFileSizeKb.
2. Storage kinds — what a property contributes to a record
Every property class answers getStorageKind() with one of three values. It is a fact about the
type, not about one field's configuration, so a property definition cannot override it. It is
what type generation, schema drift detection and the generated JSON/MCP schemas key off.
| Kind | Meaning | Types |
|---|---|---|
column | A column on the model's own table | Everything not listed below |
derived | Appears on records, but the values live elsewhere | translatable, multiselect, taxclassrates, childentries |
layout | Form chrome — never reaches a record at all | groupstart, groupend, note |
A derived property still shows up in reads (its own getSelectsForGetRecord() /
getJoinsForGetRecord() / appendDataForGetRecord() put it there); it just contributes no column
to the base table. A layout property contributes nothing anywhere — no select, no column, no
record key, no schema property.
3. storeExternally — putting a property's column in another table
Any property type whose storage kind is column can be told its column does not live in the model's
own table:
$propDefs['some_field'] = array(
'name' => 'some_field',
'type' => 'string',
'storeExternally' => true,
'foreignTableName' => '#__my_extension_product_data',
'foreignTableAlias' => 'myext',
'foreignTableKey' => 'product_id',
);
| Setting | Required when storeExternally | Meaning |
|---|---|---|
storeExternally | — | Turns the mechanism on |
foreignTableName | yes | The table actually holding the column |
foreignTableKey | yes | The column in that table pointing back at the base record's key |
foreignTableAlias | yes | Alias used in the join, and the prefix for filter names |
What it changes
Reading — a LEFT JOIN onto the foreign table on foreignTableKey = <base table>.<primary key>,
and the property selects from the foreign alias rather than the model's own table. getTableAlias()
returns foreignTableAlias instead of the model name, which is also what makes list filtering and
sorting reference the right table.
Writing — the property writes itself:
INSERT INTO `<foreignTableName>`
SET `<column>` = <value>, `<foreignTableKey>` = <base key>
ON DUPLICATE KEY UPDATE `<column>` = <value>
then unsets itself from the data object so the model's own store does not try to write a column
that is not there. Correspondingly, getDataKeysForBaseTable() returns an empty array for such a
property — that method is what the base store and copy() use to decide which keys belong to the
base table.
Deleting — KenedoModel::delete() runs every property's delete() before removing the base row,
and the base implementation issues DELETE FROM <foreignTableName> WHERE <foreignTableKey> = <id>.
Your row goes with the record.
Copying — the base copy() re-inserts the value against the new record's key. When the value is
absent it reads INFORMATION_SCHEMA for the column's nullability and default rather than guessing:
a NOT NULL column with no definition default and no column default is skipped with a warning in
the log instead of failing the copy.
NULL is preserved as SQL NULL rather than becoming ''.
Which types support it
| Support | Types |
|---|---|
Yes, via the base class — the type does not override store() | string, number, datetime, json, dropdown, radio, boolean, published, join, pseudojoin, calculation, countryselect, stateselect, countyselect, rule, groupPrice, calculationOverride, id, ordering, shapediver*, paymentmethodparams, commerce2providersettings |
Yes, implemented by the type — it overrides store() and branches on the flag itself | image, file (the filename column moves; the files themselves always live under dirBase) |
Not applicable — derived, no base-table column to move | translatable, multiselect, taxclassrates, childentries |
Not applicable — layout, no record key at all | groupstart, groupend, note |
A type that overrides store(), copy() or delete() and does not call the parent loses the
part of the mechanism that method carries. image and file call the parent where it matters;
multiselect and translatable do not, which is harmless because they are derived and there is
nothing external to clean up.
What it is for
Adding fields to a stock entity without altering a stock table — the classic customization case. Your columns live in your own table, keyed by the stock record's id, and the property behaves like any other from the form's point of view. No shipped property definition uses it; it exists for customizations, which is also why it is easy to get subtly wrong.
Things to get right
foreignTableKeymust be UNIQUE in the foreign table. The write is an upsert and relies on the duplicate-key collision; without the constraint you get a second row per save instead of an update.- The alias must be unique among the model's properties, or two external properties will collide in the same query. Two properties sharing one foreign table share one alias and one join, which is the intended way to add several columns at once.
joincomposes correctly.KenedoPropertyJoin::getJoinsForGetRecord()calls the parent first, so an externally stored join emits both the external-table join and the referenced-model join.- Type generation reads
getStorageKind(), which is stillcolumn— the storage vocabulary describes what kind of value it is, not which table.--schema-driftlooks for the column in the model's own table, so an externally stored property will be reported as missing there. That is a known gap in the diagnostic rather than a problem with your definition.
4. Choosing a type
Work down the questions; the first match is usually right.
| If the value is… | Use |
|---|---|
| free text on one line | string |
| free text needing a textarea or WYSIWYG | string with USE_TEXTAREA / USE_HTMLEDITOR |
| text the shop shows to customers in their language | translatable |
| a number of any kind — count, price, percentage | number |
| a date | datetime |
| yes/no | boolean, or published for the publish flag |
| one of a fixed, code-known set | dropdown, or radio if the set is tiny |
| one of a set that depends on data in a table | join |
| one of a set a model method computes, keyed by string | pseudojoin |
| several of a set | multiselect |
| a country, state or county | countryselect / stateselect / countyselect |
| a reference to a calculation | calculation |
| a visibility or pricing rule | rule |
| a JSON document a human may inspect | json |
| an uploaded image | image |
| any other uploaded file | file |
| a list of child records edited in place | childentries |
| nothing — it is form structure | groupstart / groupend, note |
Two recurring mistakes the table is meant to prevent:
- An id is not a number. Geography selects and joins store integers, but declaring them as
numbergives you a text input where a picker belongs. - A JSON column is not a string.
stringHTML-escapes on the way in, which corrupts JSON. Usejson, or declare no property at all if the column is engine-owned.
5. The types
Each links to its own article.
Text and numbers
| Type | Stores | Column |
|---|---|---|
string | single-line text | varchar(255) |
number | integer, decimal, money or percent | int / decimal(20,4) |
datetime | date and time | datetime, nullable |
json | a JSON document | text |
Choices
| Type | Stores | Column |
|---|---|---|
dropdown | one of a set, as a select | varchar(50) |
radio | one of a set, as radio buttons | varchar(50) |
boolean | yes/no | varchar(1) |
published | the publish flag | varchar(1) |
multiselect | several values, in an xref table | derived |
References
| Type | Stores | Column |
|---|---|---|
join | an integer foreign key | int unsigned |
pseudojoin | a string key from a model method | varchar(100) |
calculation | a calculation reference | int unsigned, nullable |
countryselect / stateselect / countyselect | cascading geography ids | int unsigned |
childentries | nothing — embeds a child list view | derived |
Text with a life of its own
| Type | Stores | Column |
|---|---|---|
translatable | one text per active language | derived — #__configbox_strings |
rule | a visibility/pricing rule | text |
Prices
| Type | Stores | Column |
|---|---|---|
groupPrice | per-customer-group price overrides | text |
calculationOverride | per-group calculation overrides | text |
taxclassrates | rates per tax class | derived |
Files
| Type | Stores | Column |
|---|---|---|
image | an uploaded image, plus generated variants | varchar(255) |
file | an uploaded file | varchar(255) |
Layout — no column, no record key
| Type | Purpose |
|---|---|
groupstart / groupend | open and close a collapsible form section |
note | static explanatory text |
Identity and ordering
| Type | Stores | Column |
|---|---|---|
id | the primary key | int unsigned auto_increment |
ordering | sort position | int unsigned NOT NULL |
Integrations
| Type | Purpose |
|---|---|
shapedivermodel and siblings | ShapeDiver model, parameter and geometry references |
paymentmethodparams | per-connector payment settings |
commerce2providersettings | per-provider Commerce 2 settings |
6. Writing your own type
The full guide — resolution, every lifecycle hook in call order, a worked colorpicker example,
assets, and the deployment checklist — is
../customization/com_configbox_custom_properties.md.
What follows is the short version, and the five things that most often go wrong.
data/customization/properties/<type>.php, classKenedoProperty<Type>.- A template is required:
data/customization/properties/tmpl/<type>.php, or overridegetAdminTemplateFile()to delegate to another type's, or overridegetBodyAdmin()to render directly. Without one the field renders as nothing — so nothing is submitted for it, and what you see is an insert failing on a NOT NULL column far from the cause. Missing templates now throw.pseudojoinis the worked example of this going wrong. - Declare your storage —
getStorageKind(),getDefaultDataType(),getDefaultNullable(). See §7 of the settings doc. - Throw from
check(), do not returnfalse— see../migration-to-cb4/exceptions-and-validation.md. - Call the parent in
store(),copy(),delete()andgetJoinsForGetRecord()unless you mean to drop what it does — that is wherestoreExternallylives (§3).
The hooks a type can implement, in the order they run on a save:
getDataFromRequest() → check() → prepareForStorage() → (model writes the base row) →
store(). On a read: getSelectsForGetRecord() + getJoinsForGetRecord() → (query) →
appendDataForGetRecord(). The per-hook detail is
../customization/com_configbox_custom_properties.md §3.
Deliver the column in a migration. A property definition does not create storage — the column or
table it maps to is added by a versioned script under helpers/updates/, idempotently. See
com_configbox_migrations.md.
See also
com_configbox_property_definition_settings.md— the storage keys, and §8 the schema-drift check that reads these definitions../customization/com_configbox_custom_properties.md— building a new type../customization/com_configbox_extending_stock_models.md— adding a field to a shipped model without touching itcom_configbox_kenedo_model.md— how the model loops over properties to read, write, copy and deletecom_configbox_type_generation.md·com_configbox_mcp_server.md— what these definitions generate- Key source:
external/kenedo/classes/KenedoProperty.php,external/kenedo/properties/