Skip to main content
Version: 3.x

Custom Rule Condition Types (for sources that are not a question)

Version
3.x
Updated
View markdown

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

  1. Declare the fields on the question type (above). Deploy; both ways of saying it now work.

  2. 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" field
    defined('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 LIKE makes 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.

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

ConcernRuleExample (type Weekday)
Filedata/customization/rule_condition_types/CustomCondition<Type>.php; the file name is what the type list is built from, so name it after the classCustomConditionWeekday.php
Class nameCustomCondition + ucfirst(type) — note the CustomCondition prefix, not ConfigboxConditionclass CustomConditionWeekday extends ConfigboxCondition
Type namederived back from the class name (getTypeName(), :150) — strip the CustomCondition/ConfigboxCondition prefixWeekday

Two naming rules that bite:

  1. Use the CustomCondition prefix. A custom class named ConfigboxConditionWeekday would 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 to CustomCondition.
  2. ucfirst(type) — the type string in the rule data is matched case-folded on the first letter. Keep your data-type attribute (§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)​

MethodReturnsPurpose
getEvaluationResult($conditionData, $selections) (:233)boolThe 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 stringThe 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 stringRenders 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)​

MethodDefaultOverride 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)trueyour 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)falseyour 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 unchangedyour 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 calcId in conditionData, like the shipped Calculation condition), you must implement the matching contains* method (return whether that id appears) so the engine knows the rule depends on it and prevents deleting it; and implement getCopiedConditionData() to remap the id when products are copied. Skipping these leads to dangling references after deletes/copies. Add a getValidationErrors() 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's getStock() 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
  1. Check it is not about a question. If it is, declare a field on the question type instead (§0); no condition class.
  2. Copy the reference docs/customization/examples/CustomConditionExample.php into data/customization/rule_condition_types/ and rename file + class to your type.
  3. Name the class CustomCondition<Ucfirst(type)> and keep data-type="<Type>" consistent (§2).
  4. Implement the three required methods (getEvaluationResult, getConditionsPanelHtml, getConditionHtml); override getTypeTitle(); restrict operators only if needed (§3).
  5. Emit the markup conventions in getConditionHtml(): span.item.condition, required data-type + data-operator, .input with data-data-key for editable values, the cb-rule-condition-name / -operator / -value spans (§4).
  6. Implement getValidationErrors() for whatever can be wrong in your data, and contains* + getCopiedConditionData() if your condition stores question/answer/calculation ids (§5).
  7. Keep getEvaluationResult() fast and cache any external lookups (§6).
  8. 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.ts in 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 CustomConditionDimensions reading a subValue) are gone. The core migration 3.8.26 unifies the QuestionProperty vocabulary; a site that had such classes rewrites their stored conditions to QuestionProperty with a field in its own migration track.
  • CustomCondition prefix, not ConfigboxCondition. Built-in classes are checked first; the custom branch needs the CustomCondition name (: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 & operator are required. Inputs write back via data-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.
  • getEvaluationResult is hot — cheap, cached, no side effects (§6).
  • Compare numbers with ConfigboxQuestionField::compare(), not version_compare(), which reads 2.10 as above 2.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.php and …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.