Custom Rule Condition Types (for sources that are not a question)
- Version
- 3.x
- Updated
How to add your own condition type to the CBX rule engine — a new kind of "if…" that admins can drop into a rule in the Rule Editor and that the engine evaluates at configure-time to show/hide questions, answers, etc. This guide is for conditions about something that is not a question: a stock level, the day of the week, external data. CBX ships the condition types that are not about a question (calculation result, customer group, negation) plus ONE generic question condition that every question type feeds; this guide shows how to add a non-question type without touching core.
The engine is encoded; the condition types are not. The rule engine itself (
ConfigboxRulesHelper) ships ionCube-encoded (helpers/encoded/), so you cannot read or patch it — its behaviour is documented in the rule engine reference instead. But condition types are plaintext PHP classes that the engine calls into (ConfigboxRulesHelper::getEvaluationResult()→ConfigboxCondition::getCondition($type)->getEvaluationResult()). So you can add condition types freely — no encoder, no engine rebuild.
Read com_configbox_customization_overview.md first; technical/com_configbox_rule_engine.md for how the
engine stores and evaluates rules, and functional/com_configbox_rule_authoring.md for the admin's view of
authoring rules. All paths are relative to the component root docroot/components/com_configbox/. Source
references are point-in-time (component 3.8.26) — verify against the code.
0. If your condition is about a question, you do not write a condition class
A condition that compares something about a question (its answer, its entry, its price, a part of a
composite selection such as the width of a dimensions question or the quantity of one answer) is not a
condition type. The question type declares it as a field, and the one generic question condition
(ConfigboxConditionQuestionProperty) does everything a condition class used to do: the chips in the
editor, the evaluation, the save-time validation, the delete guards, the id remapping on product copy
and transfer, and the description the MCP authoring tools hand to an assistant. The rule editor lists
the field under the question, next to its answers and its price.
The declaration is three methods on your ConfigboxQuestion<Type> class, modelled here on a dimensions
type whose selection is {"w":"2000","h":"1000","q":"1"}:
class ConfigboxQuestionDimensions extends ConfigboxQuestion {
/** What a rule can compare: the members, then the base fields (price, custom fields) without
* `selected` - a composite has no single entry. */
public function getRuleFields() {
return array_merge($this->getDimensionFields(), $this->withoutField(parent::getRuleFields(), 'selected'));
}
protected function getDimensionFields() {
$group = KText::_('Dimensions'); // the heading the editor groups these under
return array(
new ConfigboxQuestionField('w', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Width')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $group,
'description' => 'Width in mm, as entered', // for the API / AI
)),
new ConfigboxQuestionField('area', ConfigboxQuestionField::compose(KText::_('%s of %s'), KText::_('Area (m²)')), ConfigboxQuestionField::KIND_NUMBER, array(
'group' => $group,
'description' => 'Derived: w × h in m²',
)),
);
}
/** Reads a field from the GIVEN selection; null means "nothing there". */
public function getFieldValue($key, $selection, $answerId = null, $selections = null) {
if ($key === 'w' || $key === 'area') {
return self::getSubValue($selection, $key); // the type's own decoding helper
}
return parent::getFieldValue($key, $selection, $answerId, $selections);
}
}
The stored condition is then {"type":"QuestionProperty","questionId":42,"field":"w","operator":">=","value":"2000"},
the same shape as "Answer in … is …". The full contract (the ConfigboxQuestionField kinds,
getCalcFields() for formulas, per-answer fields, the $selections parameter) is in
com_configbox_custom_question_types.md, §2.6 "Rules and formulas — the type declares its fields".
If you already ship a condition class for such a comparison
It keeps working: the directory scan still finds it, the rules stored with its type still evaluate
through it, and the editor lists it under "Other conditions" — below the questions, where the
generic question condition now offers the fields. Retire it in three steps, in this order, from one
customization update script (data/customization/updates/<version>.php, the same runner and the same
idempotency rules as a core migration):
-
Declare the fields on the question type (above). Deploy; both ways of saying it now work.
-
Rewrite the stored conditions of your type into question conditions. Every rule column is a JSON token stream;
ConfigboxRulesJsonHelper::walk()visits each condition and hands it back rewritten, brackets and function parameters included:// data/customization/updates/1.3.0.php - "WidthOf" becomes the dimensions type's "w" fielddefined('CB_VALID_ENTRY') or die();$db = KenedoPlatform::getDb();$toField = function(array $item) {if (isset($item['type']) && $item['type'] === 'WidthOf') {return array('type' => 'QuestionProperty','questionId' => (int) $item['questionId'],'field' => 'w','operator' => $item['operator'],'value' => $item['value'],);}return $item;};foreach (array('#__configbox_questions', '#__configbox_answers') as $table) {$db->setQuery("SELECT `id`, `rules` FROM `".$table."` WHERE `rules` LIKE '%\"WidthOf\"%'");foreach ($db->loadObjectList() as $row) {$items = json_decode($row->rules, true);if (!is_array($items)) {continue;}$db->setPreparedQuery("UPDATE `".$table."` SET `rules` = ? WHERE `id` = ?", array(json_encode(ConfigboxRulesJsonHelper::walk($items, $toField)), (int) $row->id));$db->query();}}ConfigboxCacheHelper::purgeCache();The
LIKEmakes the script idempotent — a second run finds no rows. If your type is stored in a rule column of your own, add that table to the loop. -
Delete the class (and its
views/adminruleeditor_<type>/sub-view if you had one) in the release after the script has run everywhere. A rule that still names a deleted type is refused on save and throws on render, which is why the data goes first.
If the stored items of your type also carry the pre-3.8.26 question vocabulary (selectedAnswer.id,
fieldPath, a data wrapper), ConfigboxRulesJsonHelper::normalizeColumn($table, 'rules', 'my rule', '1.3.0')
brings a whole column onto the current one in a single call — that is what migration 3.8.29 runs over
every rule column a model declares; a table no model declares is yours to pass.
Come back here only when the thing the rule reacts to is not a question at all.
1. Start from the shipped example
CBX ships a fully-annotated example of a non-question condition type, a stock level that compares a
per-SKU figure with a number: docs/customization/examples/CustomConditionExample.php (class
CustomConditionStockLevel). It documents the data shapes and every method inline and is the intended
starting point. It is not loaded by the component: it lives in the docs, not in classes/.
Copy it into the customization folder and rename it to your type:
docs/customization/examples/CustomConditionExample.php (read this — the annotated reference)
↓ copy + rename file AND class
data/customization/rule_condition_types/CustomConditionWeekday.php
The rest of this guide explains the contract that example satisfies, so you understand what to change.
2. How a type resolves to your class
All condition classes are loaded eagerly when the first condition is resolved, by
ConfigboxCondition::loadConditionClasses() (classes/ConfigboxCondition.php:75-95): every .php file in
core classes/rule_condition_types/ and in data/customization/rule_condition_types/ is
include_once'd. Then, for a given type name, getCondition($type) (:34-69) picks the class:
$regularClass = 'ConfigboxCondition'.ucfirst($type); // built-in naming
$customClass = 'CustomCondition'.ucfirst($type); // custom naming
if (class_exists($regularClass)) { $class = $regularClass; } // built-in checked FIRST
elseif (class_exists($customClass)) { $class = $customClass; } // then custom
else { throw … 'Custom condition class should be called "'.$customClass.'"'; }
The list of types the editor offers comes from the file names in both folders
(getConditionClassNames(), :100; getConditionTypeNames(), :129, strips either prefix). The contract,
therefore:
| Concern | Rule | Example (type Weekday) |
|---|---|---|
| File | data/customization/rule_condition_types/CustomCondition<Type>.php; the file name is what the type list is built from, so name it after the class | CustomConditionWeekday.php |
| Class name | CustomCondition + ucfirst(type) — note the CustomCondition prefix, not ConfigboxCondition | class CustomConditionWeekday extends ConfigboxCondition |
| Type name | derived back from the class name (getTypeName(), :150) — strip the CustomCondition/ConfigboxCondition prefix | Weekday |
Two naming rules that bite:
- Use the
CustomConditionprefix. A custom class namedConfigboxConditionWeekdaywould also be found (built-in branch), but you'd be impersonating the built-in namespace — and a built-in of the same name would shadow you, since built-in is checked first (:47-52). Stick toCustomCondition.ucfirst(type)— the type string in the rule data is matched case-folded on the first letter. Keep yourdata-typeattribute (§4) consistent with the class suffix (Weekday↔CustomConditionWeekday). The suffix is persisted in every rule that uses the type; renaming it orphans them.The whole-folder eager include means don't put non-class code at file top-level — every file runs at load. Guard with
defined('CB_VALID_ENTRY') or die();and define only the class.
3. The contract — methods you implement
ConfigboxCondition (classes/ConfigboxCondition.php) is abstract with three required methods and
several optional hooks with working defaults.
Required (abstract)
| Method | Returns | Purpose |
|---|---|---|
getEvaluationResult($conditionData, $selections) (:233) | bool | The core logic. true when the condition is met for the given selections. Called by the engine every time a selection changes — keep it fast. |
getConditionsPanelHtml($ruleEditorView) (:239) | HTML string | The type's panel: the chips the admin can drag into a rule. It is shown when the type's entry under "Other conditions" (below the question list) is clicked. Return a <ul class="cb-rule-conditions-list"> of <li> wrapping getConditionHtml($blueprint). |
getConditionHtml($conditionData, $forEditing = true) (:249) | HTML string | Renders one condition as a chip: editable ($forEditing = true) in the editor, read-only wherever else the backend shows a rule. |
Optional (override to change defaults)
| Method | Default | Override when |
|---|---|---|
getValidationErrors($conditionData) (:209) | checks that operator is one of getOperators() | your data has anything that can be wrong (a missing key, an id that no longer exists, a non-numeric value). Runs when the question/answer is saved (KenedoPropertyRule::check()), so database lookups are fine; call parent to keep the operator check. |
getOperators() (:177) | <, <=, ==, !=, >=, > (with readable text) | your type needs fewer operators (add the class cb-rule-condition--short-operators to the chip for the two-entry picker) or a single fixed one. |
getTypeTitle() (:143) | KText of CONDITION_TYPE_<name> | always: it is the text of your entry under "Other conditions". |
showPanel() (:254) | true | your type should exist but get no entry (the negation marker; the question condition, whose chips are listed per question). |
containsQuestionId() / containsAnswerId() / containsCalculationId() (:267,:281,:295) | false | your condition references a question/answer/calculation — so the engine knows the rule blocks deleting that entity. Implement these if your conditionData holds such ids. |
getCopiedConditionData($conditionData, $copyIds) (:306) | returns data unchanged | your condition stores ids that must be remapped when a product is copied (see ConfigboxConditionQuestionProperty::remapReferences() for the pattern, and KenedoModel::getCopiedId()). The transfer import remaps the keys questionId, answerId and calcId of every type generically (ConfigboxRulesJsonHelper::remapReferences()). |
4. The two data shapes (know these cold)
Both are plain associative arrays; the example file documents them in its class docblock.
$selections — the customer's current configuration as the engine hands it over, which may be a
simulated one (the configurator asks "what would happen if"). Keys are question ids, values the
stored selection (an entered string, or the selected answer's id):
$selections = array(
3 => 'ABC', // customer typed "ABC" in question id 3
6 => '4', // customer chose the answer with id 4 in question id 6
);
$conditionData — one condition's stored data. It is built from the data-* attributes you emit
in getConditionHtml() plus the .input values. type and operator are required; everything else is yours:
$conditionData = array(
'type' => 'StockLevel', // = your type name (the data-type attribute)
'sku' => 'ABC-100', // your custom payload (any keys you like)
'operator' => '>=', // machine-readable relational operator
'value' => '5', // the value to compare with (from the .input)
);
The bridge between HTML and data: every data-foo-bar attribute on the span.item.condition becomes
$conditionData['fooBar'] (kebab → camelCase), and every <input class="input" data-data-key="value">
inside it writes back under its key, so every data-* attribute on the chip is a stored key; put
nothing else there. The chip's three spans are cb-rule-condition-name (the subject),
cb-rule-condition-operator (the operator picker binds to this one and writes the choice into
data-operator) and cb-rule-condition-value (read-only display). The editor JS has no per-type code:
it harvests the attributes and normalizes a numeric input for the locale decimal symbol, and that is all.
5. Worked example — a Weekday condition
A condition that is met when today is the weekday the admin picked. It needs no question/answer ids, so
the contains* and copy hooks keep their defaults.
// data/customization/rule_condition_types/CustomConditionWeekday.php
<?php
defined('CB_VALID_ENTRY') or die();
/**
* Met when the current weekday matches (operator) the admin-chosen weekday (0=Sun … 6=Sat).
* Type string 'Weekday' ⇒ class CustomConditionWeekday.
*/
class CustomConditionWeekday extends ConfigboxCondition {
/** The entry under "Other conditions" in the Rule Editor. */
function getTypeTitle() {
return KText::_('Day of week');
}
/** The panel: the chips an admin can drag in for this type. */
function getConditionsPanelHtml($ruleEditorView) {
$seed = array('type' => 'Weekday', 'operator' => '==', 'value' => '6');
ob_start(); ?>
<ul class="cb-rule-conditions-list">
<li><?php echo $this->getConditionHtml($seed); ?></li>
</ul>
<?php
return ob_get_clean();
}
/** Render one condition — editable chip or read-only display. */
function getConditionHtml($conditionData, $forEditing = true) {
$days = array('0'=>KText::_('Sunday'),'1'=>KText::_('Monday'),'2'=>KText::_('Tuesday'),
'3'=>KText::_('Wednesday'),'4'=>KText::_('Thursday'),'5'=>KText::_('Friday'),'6'=>KText::_('Saturday'));
$current = isset($conditionData['value']) ? (string) $conditionData['value'] : '6';
$operator = isset($conditionData['operator']) ? (string) $conditionData['operator'] : '==';
ob_start(); ?>
<span class="item condition weekday"
data-type="Weekday"
data-operator="<?php echo hsc($operator); ?>">
<span class="cb-rule-condition-name"><?php echo hsc(KText::_('Day of week')); ?></span>
<span class="cb-rule-condition-operator"><?php echo hsc($this->getOperatorText($operator)); ?></span>
<?php if ($forEditing): ?>
<select class="input" data-data-key="value">
<?php foreach ($days as $val => $label): ?>
<option value="<?php echo hsc($val); ?>" <?php echo ($val === $current) ? 'selected' : ''; ?>><?php echo hsc($label); ?></option>
<?php endforeach; ?>
</select>
<?php else: ?>
<span class="cb-rule-condition-value"><?php echo hsc(isset($days[$current]) ? $days[$current] : $current); ?></span>
<?php endif; ?>
</span>
<?php
return ob_get_clean();
}
/** The logic: compare today's weekday against the chosen one with the chosen operator. */
function getEvaluationResult($conditionData, $selections) {
$today = (int) date('w'); // 0..6
$should = isset($conditionData['value']) ? (int) $conditionData['value'] : -1;
$operator = isset($conditionData['operator']) ? $conditionData['operator'] : '==';
// The numeric comparison the question fields use, so all six operators behave alike
return ConfigboxQuestionField::compare($today, $should, $operator);
}
}
What we relied on the base class for: operator text/list (getOperators()/getOperatorText()), the type
name derivation, the operator validation on save, and the no-op contains*/copy hooks (this type holds no
entity ids). Drop the file in place, open the Rule Editor, and "Day of week" appears under "Other
conditions".
If your condition does reference questions/answers/calculations (e.g. you store a
calcIdinconditionData, like the shippedCalculationcondition), you must implement the matchingcontains*method (return whether that id appears) so the engine knows the rule depends on it and prevents deleting it; and implementgetCopiedConditionData()to remap the id when products are copied. Skipping these leads to dangling references after deletes/copies. Add agetValidationErrors()check that the id still exists, too: a condition on a deleted record otherwise evaluates to false with no error anywhere. And if what you want to reference is a question's value, go back to §0.
6. Performance — getEvaluationResult runs a lot
getEvaluationResult() is called by the engine every time the visitor changes a selection, for every
rule that uses your condition, inside the configurator's consistency loop. The engine memoizes rule
results per request, but your method body runs per evaluation. So:
- Keep it cheap and side-effect-free; do simple comparisons on data already in
$conditionData/$selections. - If you must load external data (customer record via
ConfigboxUserHelper::getUser(), a DB lookup, a stock feed), cache it statically within the request — the example'sgetStock()is the place it calls this out. - Never write to the DB or session here.
7. Deployment checklist
data/customization/
rule_condition_types/
CustomCondition<Type>.php ← class CustomCondition<Type> extends ConfigboxCondition
- Check it is not about a question. If it is, declare a field on the question type instead (§0); no condition class.
- Copy the reference
docs/customization/examples/CustomConditionExample.phpintodata/customization/rule_condition_types/and rename file + class to your type. - Name the class
CustomCondition<Ucfirst(type)>and keepdata-type="<Type>"consistent (§2). - Implement the three required methods (
getEvaluationResult,getConditionsPanelHtml,getConditionHtml); overridegetTypeTitle(); restrict operators only if needed (§3). - Emit the markup conventions in
getConditionHtml():span.item.condition, requireddata-type+data-operator,.inputwithdata-data-keyfor editable values, thecb-rule-condition-name/-operator/-valuespans (§4). - Implement
getValidationErrors()for whatever can be wrong in your data, andcontains*+getCopiedConditionData()if your condition stores question/answer/calculation ids (§5). - Keep
getEvaluationResult()fast and cache any external lookups (§6). - Verify — add the condition to a rule in the Rule Editor (your entry is under "Other conditions"),
save, reconfigure on the frontend and confirm the show/hide fires; then delete/copy the product and
confirm no dangling reference. A Playwright spec against the site is the durable form of that check
(
tests/specs/backend/rule-validation.spec.tsin cbx-joomla is the pattern).
8. Conventions & gotchas
- A condition about a question is a field on the question type, not a class here (§0). The old
per-type condition classes (a
CustomConditionDimensionsreading asubValue) are gone. The core migration3.8.26unifies theQuestionPropertyvocabulary; a site that had such classes rewrites their stored conditions toQuestionPropertywith afieldin its own migration track. CustomConditionprefix, notConfigboxCondition. Built-in classes are checked first; the custom branch needs theCustomConditionname (:47-52).- Every file in the folder runs at load (eager include). One class per file, no top-level side effects,
always
defined('CB_VALID_ENTRY') or die();. data-*⇒conditionData(kebab→camelCase);type&operatorare required. Inputs write back viadata-data-key. The editor JS handles the operator picker (bound to.cb-rule-condition-operator) and decimal normalization; it has no per-type code.- Implement
contains*if you hold ids — otherwise the engine can't protect referenced entities from deletion, and copy won't remap ids. getEvaluationResultis hot — cheap, cached, no side effects (§6).- Compare numbers with
ConfigboxQuestionField::compare(), notversion_compare(), which reads2.10as above2.5. - The engine is encoded but condition types aren't — you don't need the ionCube encoder or the
unencoded/sources to add a condition type. - Escape output with
hsc()in the HTML methods; this is admin-facing markup but still escape stored values.
See also
docs/customization/examples/CustomConditionExample.php: the annotated reference (a stock-level condition; start here).classes/rule_condition_types/ConfigboxConditionCalculation.phpand…CustomerGroup.php: the two shipped non-question types to copy patterns from;…QuestionProperty.php: the generic question condition that reads the fields question types declare.classes/ConfigboxCondition.php: the base class, with the resolution (getCondition,:34), the loader (:75), and every overridable hook with its default.com_configbox_custom_question_types.md, §2.6 "Rules and formulas — the type declares its fields": declaring fields on a question type, which is what a condition about a question is.technical/com_configbox_rule_engine.md: how rules are stored and evaluated (the engine that calls your condition).functional/com_configbox_rule_authoring.md: the admin's view of building rules with conditions.com_configbox_customization_overview.md: the extension-point map.