Custom Question Types
- Version
- 4.0 preview
- Updated
How to add your own question type to CBX — a new kind of input the configurator renders, with
its own template, its own admin fields, and its own rules about what a selection means. CBX ships
twelve types (textbox, dropdown, radiobuttons, images, slider, calendar, upload, …);
this guide shows how to add one without touching core.
Read com_configbox_customization_overview.md first, and
technical/com_configbox_configurator_questions.md for how questions, answers and selections work
underneath. All paths are relative to the component root docroot/components/com_configbox/;
getDirCustomization() is KenedoPlatform::p()->getDirCustomization(), which resolves per platform
(Joomla data/customization/, WordPress the separate customization plugin, Magento the
ConfigboxCustomizations module). Source references are point-in-time — verify against the code.
1. The one thing to know first: the view folder registers the type
A custom question type becomes selectable in the admin because a view folder exists, not
because you wrote a class. ConfigboxModelQuestions::getCustomQuestionTypes()
(models/questions.php:1538) simply lists folders:
$folder = KenedoPlatform::p()->getDirCustomization().'/views/';
$folders = KenedoFileHelper::getFolders($folder, 'question_');
// 'question_dimensions' -> choice 'dimensions' labelled 'Dimensions'
Those are merged into the question_type dropdown's choices (models/questions.php:147). So:
getDirCustomization()/views/question_dimensions/ ← this alone adds "Dimensions" to the dropdown
view.html.php
tmpl/
default.php
The label is ucfirst() of the folder suffix. Nothing else registers a type — no XML, no
install step, no database row. Create the folder, and the type is offerable.
2. The class is optional — and it is where behaviour lives
Rendering is the view's job; meaning is the class's. ConfigboxQuestion::getQuestion()
(classes/ConfigboxQuestion.php:111) resolves one per question:
$className = 'ConfigboxQuestion'.ucfirst($questionData->question_type); // 'ConfigboxQuestionDimensions'
and looks for it in this order:
- already declared (an autoloader or an earlier
requiregot there first), - core
classes/question_types/<ClassName>.php, getDirCustomization()/question_types/<ClassName>.php— yours,- no class found → plain
ConfigboxQuestion, silently.
Point 4 is the trap worth internalising: a typo in the class name or filename does not raise an error. The type keeps working, with base-class behaviour, and every override you wrote is ignored. If your custom logic "does nothing", check this first.
getQuestion()returns aclone. State you set on the returned object is per-call and does not persist to the nextgetQuestion()for the same id. Do not cache anything on$thisexpecting it to survive.
3. The contract — what to override
ConfigboxQuestion is a concrete class, not an abstract one: override only what your type needs.
The methods that matter, grouped by job (classes/ConfigboxQuestion.php):
The selection lifecycle
| Method | Job |
|---|---|
onBeforeSetSelection(&$selection, $prevSelection, $cartPositionId) | Last chance to normalise or reject a value before it is stored. $selection is by reference — rewrite it here |
onAfterSetSelection($selection, $prevSelection, $cartPositionId) | Side effects once stored (recalculate, clear a dependent question) |
What a value is
| Method | Job |
|---|---|
getStorableValue($selection) | What goes in the database |
getComparableValue($selection) | What rules and calculations compare against — the numeric or scalar reduction |
getOutputValue($selection = null) | What a human sees: cart lines, order confirmations, e-mails |
getSku($selection) | The SKU the selection stands for, frozen onto the order line. Base: the picked answer's sku, else NULL |
getInitialValue() | The value a fresh configuration starts with |
getRawValue() | The unprocessed stored value |
Giving the order line a SKU — getSku($selection)
getSku() is the one to override if your type is ordered rather than merely configured. The base
implementation knows exactly one source, the picked answer's sku column, so an answer-taking
type needs nothing from you. A free-entry type has no answer record to read: without an override
it returns NULL and the order line freezes an empty SKU, however precisely the selection describes
a real part.
class ConfigboxQuestionDimensions extends ConfigboxQuestion {
/**
* '{"w":"1200","h":"800","q":"2"}' -> 'PANEL-1200-800'
*/
public function getSku($selection) {
if ($selection === null || $selection === '') {
return null;
}
$data = json_decode($selection, true);
if (empty($data['w']) || empty($data['h'])) {
return null;
}
return 'PANEL-'.intval($data['w']).'-'.intval($data['h']);
}
}
Four rules, all of them things the caller relies on:
- Return
NULL, never'', when the selection stands for no SKU. The caller casts ((string) $question->getSku(...)), soNULLand''land in the database identically — but onlyNULLlets a sub-class of your class tell "no SKU" from "an SKU that is the empty string", and it is what the base implementation returns. - Work from the
$selectionyou are given, not fromConfigboxConfiguration::getInstance()->getSelection(). UnlikegetOutputValue()the parameter is required and never means "go and look it up" — the order record calls this while freezing a cart position's stored value, which is not necessarily the live configuration. - Decode composite values yourself. Same trap as the rest of the value group: a JSON selection is a string to the base class.
- Don't assume it is called once per order. Treat it as a pure function of the selection; do no writes, and keep it cheap.
Core calls it in exactly one place: ConfigboxModelOrderRecord::createOrderRecord(), which freezes
the result into #__cbcheckout_order_configurations.option_sku for every selection on the position.
That column is what the order overview, the notification e-mails and the shop-manager screens print
when the sku_in_order_record setting is on, and what an ERP or fulfilment integration reads off the
order. It is a freeze: the value is captured at checkout, so changing your derivation later moves
new orders only — past orders keep the SKU they were placed with, by design.
Composing a whole product code from getSku() — the pattern for a live "configuration code"
(catalogue number) that assembles as the customer configures. Because getSku() is a method of
the type, one composer can walk every question of the product without knowing any type's
internals: load each question via ConfigboxQuestion::getQuestion(), skip the ones that do not
applies() or whose selection isEmptySelection(), collect the non-empty getSku($selection)
values in catalogue order (page ordering, then question ordering), and join. Stock answer
questions contribute the picked answer's sku column through the base implementation, so the
composer works for mixed products for free. Two seams make it live: render the composed code
server-side in a page-layout template (see com_configbox_overriding_views_and_templates.md),
and append it to every selection response through the postMakeSelection(&$response) hook
(see com_configbox_overriding_controllers_and_models.md), which a small JS module reads off the
serverResponseReceived event. Because the code bar, the responses and the order-line freeze all
read the same getSku(), they can never disagree. Worked example: the Beta Calco configurator in
the cbx-joomla site customization (lib/BetacalcoCode.php + templates/configuratorpage/betacalco.php
system_overrides/postMakeSelection.php).
Validation
| Method | Job |
|---|---|
isValidValue($value) | The gate. Return false to reject |
getMinimumValue() / getMaximumValue() | Bounds, if the type has them |
isValueTooLow($value) / isValueTooHigh($value) | Bound checks, if "too low" is not a plain < |
getValidationMessage($limitValue, $tooHigh = true) | The message the customer reads |
Money and weight
Do not override getPrice() / getPriceRecurring() / getWeight() — they are not consulted.
The methods exist on the base class as thin delegates, but every real consumer (the question view's
price display, the stock QuestionProperty rule condition and calc term) calls the ConfigboxPrices
statics directly, so an override on your type class changes nothing anywhere that matters. And the
static per-answer path prices a selection only when it is a bare answer id
($question->answers[$selection]) — a composite JSON selection prices at 0, silently.
A type whose price depends on its value (per foot, per quantity, per m²) is priced by assigning a
formula calculation to the question's calcmodel, built from a custom calc term that reads the
selection through the type's own sub-value semantics — see
com_configbox_custom_calc_term_types.md. That runs inside CBX's engine on every path (display,
cart, order freeze). Weight works the same way through calcmodel_weight.
A composite type overrides most of the "what a value is" group. If your selection is JSON — a
width/height/quantity triple, say — the base class cannot do anything sensible with it:
floatval() of a JSON string is 0, so comparisons, prices and validation all silently see zero
until you decode it yourself.
4. The template chain
ConfigboxViewQuestion::renderView() (views/question/view.html.php:362) tries five paths in
order and uses the first that exists:
| # | Path | Use |
|---|---|---|
| 1 | platform template override for com_configbox / <viewName> | A site template overriding your type |
| 2 | getDirCustomization()/templates/<viewName>/<template>.php | Re-skin one question type |
| 3 | getDirCustomization()/templates/question/<template>.php | Re-skin every question |
| 4 | <yourViewFolder>/tmpl/<template>.php | Your type's own template — the normal home |
| 5 | core views/question/tmpl/<template>.php | The base fallback |
<viewName> is your folder name, question_dimensions. $template defaults to default, and is
stripped of / and . before use, so it can never escape the folder.
The wrapper adds a type-<question_type> CSS class to every question
(views/question/view.html.php:165), which is the hook to style yours.
5. Per-type admin fields
Extra fields on the question form come from the customization layer's property definitions:
getDirCustomization()/model_property_customization/questions.php → customPropertyDefinitionsQuestions()
KenedoModel::getCustomPropertyDefinitions() loads that file by the model's own base name
(questions for ConfigboxModelQuestions; legacy alias filenames like adminquestions.php from
the retired admin-prefixed model era still load, but new code targets the canonical name) and
calls a function whose name it derives from the filename. Gate each field on your type so it only
appears where it belongs:
$appliesToDimensions = ['question_type' => 'dimensions'];
$propDefs['dim_min_width'] = [
'name' => 'dim_min_width',
'label' => KText::_('Minimum width (mm)'),
'type' => 'number',
'appliesWhen' => $appliesToDimensions,
'apiTitle' => 'Minimum width',
'apiDescription' => 'Lower bound in mm for the dimensions widget, enforced server-side. Empty means no limit.',
];
appliesWhen accepts several keys and ANDs them, so a field can depend on your type and on one
of your own toggles.
The fields you add here become fields of the question entity's API: they land in the generated
JSON schemas, PHP record stubs and TypeScript types (marked x-configbox-origin: customization,
with the appliesWhen condition stated). Give every stored property apiTitle/apiDescription —
the schema-reader's wording, falling back to label/tooltip — and regenerate with
php cli/joomla.php configbox:generate-types after changing definitions. See
com_configbox_extending_stock_models.md §4.3.
Rename the file, rename the function. The loader derives the function name from the filename. A mismatch is skipped silently — no error, the properties simply never appear.
Form positions are group-scoped — a field inside another feature's group range vanishes. The form sorts ALL properties by
positionForm, and agroupstart…groupendpair swallows every field whose position falls between them, including another feature's. If that group is gated on a differentquestion_type, your fields render inside adisplay:nonecontainer: present in the DOM, invisible in the form, no error anywhere. Two rules keep you safe: wrap your type's fields in their owngroupstart/groupend(gated like the fields), and pick a position range no other group spans — check with apositionFormgrep over the customization's property files before choosing. (Found the hard way: a new type's fields at 6100–6156 disappeared into another type's group spanning 6100–6160.)
Storing those values without altering vendor tables is a separate concern — see
com_configbox_custom_properties.md for external storage and custom property types.
6. The frontend JS contract — register the type or the page dies
configurator.initQuestions() looks every question's data-question-type up in the registered
types and throws on a miss — inside the loop, so ONE question of an unregistered type stops
every question after it and the page never reaches questions-init-done. Registration is not
optional even for a purely-presentational type.
Register in an AMD module under getDirCustomization()/assets/javascript/ (the configbox/custom
AMD namespace maps there), pulled in by the auto-loaded custom_questions.js:
define(['cbj', 'configbox/configurator'], function ($, configurator) {
'use strict';
return configurator.defineQuestionType('mytype', {
initEach: function () { /* bind widgets */ },
onSystemSelectionChange: function (event, questionId, selection) { /* re-seed widget */ }
});
});
configurator.defineQuestionType(type, methods) fills the nine required handler methods with
no-ops (and standard validation-message handling) so you write only what your type needs;
configurator.registerQuestionType() underneath still enforces the full set for hand-built
objects.
7. Deployment checklist
getDirCustomization()/views/question_<type>/view.html.php+tmpl/default.php— the type now appears in the admin dropdown.getDirCustomization()/question_types/ConfigboxQuestion<Type>.php— only if it needs behaviour.- The JS registration of §6 — mandatory as soon as a question of the type can render.
model_property_customization/questions.php— per-type admin fields, gated withappliesWhen.- A migration under
updates/if the fields need storage — seetechnical/com_configbox_migrations.md. - Regenerate the API artifacts after changing property definitions:
php cli/joomla.php configbox:generate-types(commitgenerated/together with the change). - Clear the CBX cache. Question data is cached (
ConfigboxCacheHelper::getQuestionData()), and on some platforms CBX keeps its own file cache the host's cache-clear does not touch. - For a type the chat advisor and headless clients should understand and drive:
com_configbox_question_types_and_ai.md— the self-description contract (getSelectionFormatHint/getSelectionSchema, canonical storage, comparison semantics).
8. Gotchas
- A missing class is silent. See §2. Verified by making the type behave differently, never by the file existing.
getQuestion()clones. Per-call state only.- Front end: write
data('selection')when you store. The core sets a question div'sdata('selection')from the response tomakeSelection. A custom widget that stores a value without also writing its owndata('selection')will be read as empty by anything that composes a live overlay of current selections — a cross-question calculation then silently misses the value the customer just typed. Write it yourself on store. - Composite values need every value method. Overriding
isValidValue()alone leaves prices, rules and confirmations readingfloatval(<json>) === 0. - A SKU on the order line needs
getSku(), not a system override. Core reads the SKU from the question, so the supported way to put one on an order line is the override above — patchingConfigboxModelOrderRecordthroughsystem_overrides/gets you the same value and a copy of a 1000-line method to keep in sync forever. (A pre-4.x customization that already declares its owngetSku()on a question class: check the signature — see../migration-to-cb4/breaking-changes-log.md.) - The label is
ucfirst()of the folder — and that string doubles as a KText key. For a proper label, add a language-override entry under the ucfirst'd suffix: a folderquestion_lengthofrunlooks upLengthofrun, soLengthofrun="Length of run"(and its de-DE sibling) names the dropdown entry without touching core or restating the choices. An untranslated key passes through unchanged.
See also
com_configbox_question_types_and_ai.md— making the type self-describing for the chat advisor and headless clients (selection shape design, descriptors, comparison contract)com_configbox_customization_overview.md— the layer, and where it lives per platformcom_configbox_custom_properties.md— custom property types and external storagecom_configbox_custom_calc_term_types.md— making a composite value usable in pricingcom_configbox_overriding_views_and_templates.md— the wider template override storytechnical/com_configbox_configurator_questions.md— questions, answers and selections