# Custom Question Types

> 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…

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/customization/custom_question_types. Last updated 2026-08-14.

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

```php
$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:

```php
$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

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

```php
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

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

```php
$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 `groupstart`…`groupend` 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`:

```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
