Skip to main content
Version: 3.x

Kenedo property types — overview

Version
3.x
Updated
View markdown

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 wantRead
Which type to use, and what it doesthis document and the per-type articles
The storage keys — dataType, nullable, unique, maxLengthcom_configbox_property_definition_settings.md
How properties drive a model's CRUDcom_configbox_kenedo_model.md §3, §7.3
Where properties sit in the frameworkcom_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 generatecom_configbox_type_generation.md · com_configbox_mcp_server.md

1. Settings every type accepts

Identity and labelling

SettingMeaning
nameRequired. The property name. Also the record key and, by default, the column name
labelShown next to the field and used in validation messages
labelListColumn heading in the list view, when it should differ from label
tooltipHelp text on the form
hideAdminLabelRender the field without its label
apiTitleThe field's short name for an API consumer. Falls back to label
apiDescriptionThe field's prose for an API consumer. Falls back to tooltip

Adding or changing a property? Write apiTitle and apiDescription too. label and tooltip are 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 @property line (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

SettingMeaning
positionFormSort order in the edit form. Properties are ordered by this, not by declaration order
positionListSort order in the list view. Absent means the field is not in the list at all
listCellWidthCSS width for the list column, e.g. '90px'

Behaviour in the list

SettingMeaning
canSortByColumn header becomes a sort control
addSearchBoxField participates in the list's search
addDropdownFilterField gets a filter dropdown above the list
makeEditLinkCell links to the record's edit form. Needs component and controller
component / controllerWhere makeEditLink points

Validation and visibility

SettingMeaning
requiredThe form may not be submitted with this empty. Not the same as nullable
defaultValue a new record gets; also the column default in generated DDL
invisibleField is rendered but hidden — use for values set programmatically that must still persist
appliesWhenConditional relevance — see below
platformsArray 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
optionsSpace-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:

FlagRead byEffect
ALLOW_RAWbase getDataFromRequest()Take the POSTed value unfiltered
ALLOW_HTMLbase getDataFromRequest()Filter as HTML rather than stripping it
USE_TEXTAREAstring templateRender a <textarea> instead of an <input>
USE_HTMLEDITORstring / translatable templatesRender the platform's WYSIWYG editor
NO_SAVE_FILENAMEimage, fileOpt out of writing the filename to the column. Rarely wanted — see below
FILENAME_TO_RECORD_IDimage, fileName the stored file after the record id
NODELETEFILEimage, fileSuppress 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 leading 1 as "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, and 1 is 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 as null and 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.

KindMeaningTypes
columnA column on the model's own tableEverything not listed below
derivedAppears on records, but the values live elsewheretranslatable, multiselect, taxclassrates, childentries
layoutForm chrome — never reaches a record at allgroupstart, 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',
);
SettingRequired when storeExternallyMeaning
storeExternallyTurns the mechanism on
foreignTableNameyesThe table actually holding the column
foreignTableKeyyesThe column in that table pointing back at the base record's key
foreignTableAliasyesAlias 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.

DeletingKenedoModel::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

SupportTypes
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 itselfimage, file (the filename column moves; the files themselves always live under dirBase)
Not applicablederived, no base-table column to movetranslatable, multiselect, taxclassrates, childentries
Not applicablelayout, no record key at allgroupstart, 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

  • foreignTableKey must 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.
  • join composes 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 still column — the storage vocabulary describes what kind of value it is, not which table. --schema-drift looks 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 linestring
free text needing a textarea or WYSIWYGstring with USE_TEXTAREA / USE_HTMLEDITOR
text the shop shows to customers in their languagetranslatable
a number of any kind — count, price, percentagenumber
a datedatetime
yes/noboolean, or published for the publish flag
one of a fixed, code-known setdropdown, or radio if the set is tiny
one of a set that depends on data in a tablejoin
one of a set a model method computes, keyed by stringpseudojoin
several of a setmultiselect
a country, state or countycountryselect / stateselect / countyselect
a reference to a calculationcalculation
a visibility or pricing rulerule
a JSON document a human may inspectjson
an uploaded imageimage
any other uploaded filefile
a list of child records edited in placechildentries
nothing — it is form structuregroupstart / 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 number gives you a text input where a picker belongs.
  • A JSON column is not a string. string HTML-escapes on the way in, which corrupts JSON. Use json, or declare no property at all if the column is engine-owned.

5. The types

Each links to its own article.

Text and numbers

TypeStoresColumn
stringsingle-line textvarchar(255)
numberinteger, decimal, money or percentint / decimal(20,4)
datetimedate and timedatetime, nullable
jsona JSON documenttext

Choices

TypeStoresColumn
dropdownone of a set, as a selectvarchar(50)
radioone of a set, as radio buttonsvarchar(50)
booleanyes/novarchar(1)
publishedthe publish flagvarchar(1)
multiselectseveral values, in an xref tablederived

References

TypeStoresColumn
joinan integer foreign keyint unsigned
pseudojoina string key from a model methodvarchar(100)
calculationa calculation referenceint unsigned, nullable
countryselect / stateselect / countyselectcascading geography idsint unsigned
childentriesnothing — embeds a child list viewderived

Text with a life of its own

TypeStoresColumn
translatableone text per active languagederived#__configbox_strings
rulea visibility/pricing ruletext

Prices

TypeStoresColumn
groupPriceper-customer-group price overridestext
calculationOverrideper-group calculation overridestext
taxclassratesrates per tax classderived

Files

TypeStoresColumn
imagean uploaded image, plus generated variantsvarchar(255)
filean uploaded filevarchar(255)

Layout — no column, no record key

TypePurpose
groupstart / groupendopen and close a collapsible form section
notestatic explanatory text

Identity and ordering

TypeStoresColumn
idthe primary keyint unsigned auto_increment
orderingsort positionint unsigned NOT NULL

Integrations

TypePurpose
shapedivermodel and siblingsShapeDiver model, parameter and geometry references
paymentmethodparamsper-connector payment settings
commerce2providersettingsper-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.

  1. data/customization/properties/<type>.php, class KenedoProperty<Type>.
  2. A template is required: data/customization/properties/tmpl/<type>.php, or override getAdminTemplateFile() to delegate to another type's, or override getBodyAdmin() 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. pseudojoin is the worked example of this going wrong.
  3. Declare your storagegetStorageKind(), getDefaultDataType(), getDefaultNullable(). See §7 of the settings doc.
  4. Throw from check(), do not return false — see ../migration-to-cb4/exceptions-and-validation.md.
  5. Call the parent in store(), copy(), delete() and getJoinsForGetRecord() unless you mean to drop what it does — that is where storeExternally lives (§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