Skip to main content
Version: 3.x

Custom Question Types

Version
3.x
Updated
View markdown

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:

  1. already declared (an autoloader or an earlier require got there first),
  2. core classes/question_types/<ClassName>.php,
  3. getDirCustomization()/question_types/<ClassName>.php — yours,
  4. 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 a clone. State you set on the returned object is per-call and does not persist to the next getQuestion() for the same id. Do not cache anything on $this expecting 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

MethodJob
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

MethodJob
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(...)), so NULL and '' land in the database identically — but only NULL lets 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 $selection you are given, not from ConfigboxConfiguration::getInstance()->getSelection(). Unlike getOutputValue() 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

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

#PathUse
1platform template override for com_configbox / <viewName>A site template overriding your type
2getDirCustomization()/templates/<viewName>/<template>.phpRe-skin one question type
3getDirCustomization()/templates/question/<template>.phpRe-skin every question
4<yourViewFolder>/tmpl/<template>.phpYour type's own template — the normal home
5core views/question/tmpl/<template>.phpThe 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 a groupstartgroupend pair swallows every field whose position falls between them, including another feature's. If that group is gated on a different question_type, your fields render inside a display:none container: present in the DOM, invisible in the form, no error anywhere. Two rules keep you safe: wrap your type's fields in their own groupstart/groupend (gated like the fields), and pick a position range no other group spans — check with a positionForm grep 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

  1. getDirCustomization()/views/question_<type>/view.html.php + tmpl/default.php — the type now appears in the admin dropdown.
  2. getDirCustomization()/question_types/ConfigboxQuestion<Type>.php — only if it needs behaviour.
  3. The JS registration of §6 — mandatory as soon as a question of the type can render.
  4. model_property_customization/questions.php — per-type admin fields, gated with appliesWhen.
  5. A migration under updates/ if the fields need storage — see technical/com_configbox_migrations.md.
  6. Regenerate the API artifacts after changing property definitions: php cli/joomla.php configbox:generate-types (commit generated/ together with the change).
  7. 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.
  8. 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's data('selection') from the response to makeSelection. A custom widget that stores a value without also writing its own data('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 reading floatval(<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 — patching ConfigboxModelOrderRecord through system_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 own getSku() 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 folder question_lengthofrun looks up Lengthofrun, so Lengthofrun="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 platform
  • com_configbox_custom_properties.md — custom property types and external storage
  • com_configbox_custom_calc_term_types.md — making a composite value usable in pricing
  • com_configbox_overriding_views_and_templates.md — the wider template override story
  • technical/com_configbox_configurator_questions.md — questions, answers and selections