Skip to main content
Version: 3.x

The Rule Engine, in Detail

Version
3.x
Updated
View markdown

Scope: how conditional logic ("rules") is created, stored, processed, and — most importantly — extended in the customization layer · Last reviewed: 2026-08-02

Naming: this page uses the 4.0 (CB4) vocabulary — QuestionProperty conditions, questionId, selectedAnswer. field paths. CBX 3.x used the legacy element vocabulary (ElementAttribute, elementId, selectedOption.); the 4.0 migration rewrites stored rule JSON automatically — the full before/after map is in migration-to-cb4/element-question-rename.md.

All paths are relative to docroot/components/com_configbox/.


0. Mental model

A rule decides a single boolean: does this question/answer apply? It is attached to a question (#__configbox_questions.rules) or to an answer (#__configbox_answers.rules).

  • For a question: rule true ⇒ show the question; with negation ⇒ hide it when true.
  • For an answer: same, for an individual option.

A rule is a tree of conditions joined by combinators (AND/OR) and grouped by brackets, optionally wrapped in a negation. Each condition is an instance of a condition type (a PHP class). The engine turns the tree into a PHP boolean expression and eval()s it. The effects (show/hide/grey-out/auto-select/require) are not part of the rule — they're driven elsewhere by calling the rule and reacting to the boolean (see §4.5).

There are 4 built-in condition types (classes/rule_condition_types/): QuestionProperty, Calculation, CustomerGroup, and the internal Negation. A fifth file, CustomConditionExample.php, is the documented template for custom types.


1. Storage — what a rule actually is

A rule is a JSON string stored in a single text column:

  • #__configbox_questions.rules — question-level rule (models/questions.php:581, property type=>'rule').
  • #__configbox_answers.rules — answer-level rule (models/answers.php:508).

The column is wired up as a Kenedo property of type rule (external/kenedo/properties/rule.phpKenedoPropertyRule). That property class:

  • renders the human-readable rule in list cells via ConfigboxRulesHelper::getRuleHtml(...),
  • re-maps IDs when a product is copied (copyRule()), and
  • validates the rule before it is stored (check()getRuleErrors(), §6).

The JSON itself is produced entirely client-side by the rule editor JS and written into a hidden form field; the normal Kenedo model store() then persists that string verbatim. So "saving a rule" is "saving a string column", with one gate in front of it: the property refuses a rule the engine could not evaluate (§6). Nothing parses the rule for meaning on the way in — the structure and the references are checked, the outcome is not.

The JSON shape

A rule is a JSON array of items. Each item is one of:

ItemShape
condition{ "type": "<TypeName>", "operator": "==", ...data }
combinator{ "type": "combinator", "kind": "AND" } (or "OR")
bracketa nested array of items (no type key)
negation{ "type": "negation" } — only ever the first item

The ...data of a condition are arbitrary key/values the type defines (see §5). Example (question shows only when answer 22 is chosen in question 7 AND the slider in question 8 is ≥ 5):

[
{ "type": "QuestionProperty", "questionId": 7, "field": "selectedAnswer.id", "operator": "==", "value": "22" },
{ "type": "combinator", "kind": "AND" },
[
{ "type": "QuestionProperty", "questionId": 8, "field": "selection", "operator": ">=", "value": 5 }
]
]

A negated version prepends { "type": "negation" } to the array. An empty rule is '' or [] (meaning "always applies").


2. Creating a rule in the backend (the editor)

2.1 Where the editor lives

  • Controller: controllers/adminruleeditor.php — a thin shell; its only job is to return the view.
  • View: views/adminruleeditor/view.html.php (ConfigboxViewAdminRuleeditor) + tmpl/default.php.
  • JS module: assets/javascript/rule-editor.js (loaded via configbox/ruleEditor::initRuleEditor, see view.html.php:getJsInitCallsEach).
  • CSS: assets/css/rule-editor.css.

2.2 How it opens

On a question/answer edit form, the rule property renders (properties/tmpl/rule.php):

  • a hidden input.data-field holding the current JSON, decorated with data-editor-url, data-product-id, data-page-id, data-usage-in (question|answer);
  • a read-only HTML rendering of the current rule (ConfigboxRulesHelper::getRuleHtml($rule, false));
  • buttons: Change / Delete / Copy / Paste; and an empty .rule-editor-modal.

Clicking Change (handler on .trigger-edit-rule, assets/javascript/properties/rule.js:91 — the rule property's own JS module, no longer admin.js) loads the editor view (by data-editor-url) into a Bootstrap modal and stores a back-reference to the originating form property via modal.data('form-property', ...) (rule.js:139). The current rule JSON is passed to the view as the rule request param.

2.3 How the editor builds its panels (server side)

ConfigboxViewAdminRuleeditor::prepareTemplateVars() (view.html.php:96):

  1. Reads rule, productId, pageId, usageIn from the request.
  2. Renders the current rule into editable HTML: ConfigboxRulesHelper::getRuleHtml($rule) (§3.4).
  3. Picks the heading text by context ("Show the question if…" vs "Show the answer if…") and the negated variants.
  4. Enumerates all condition types — ConfigboxCondition::getConditionTypeNames() (§5.1) — and for each whose showPanel() is true, builds a tab title (getTypeTitle()) and a panel of draggable available conditions (getConditionsPanelHtml($this)).

Minor latent bug worth knowing: the intended tab ordering array uses plural names ('Calculations', 'CustomerGroups') that don't match the real type names, and the if ($key) guard treats the index-0 type (QuestionProperty) as falsy. The net effect is the "preferred ordering" block is largely a no-op; tabs fall back to discovery order. Harmless, but surprising.

2.4 The editing surface (tmpl/default.php)

  • A .cb-rule-area drop zone (the rule being built) — tmpl/default.php:29.
  • #combinator-blueprints: draggable AND / OR spans (<span class="item combinator" data-type="combinator" data-kind="AND">).
  • #condition-picker: one tab + panel per condition type; each panel lists draggable <span class="item condition" …> blueprints.
  • A #cb-editor-operator-picker-blueprint (full = 6 operators, short = is/is-not, tmpl/default.php:69) shown when the user clicks a condition's operator.
  • A negation <select class="cb-rule-negated"> (Show-if vs Hide-if, tmpl/default.php:23).
  • Buttons: Put-in-brackets, Remove-selected, Cancel, Save.

The editor's own DOM selectors carry a cb- prefix (.cb-rule-area, .cb-rule-negated, #cb-editor-operator-picker-blueprint); the item/condition markup contract in §5.3 is unchanged.

The user drags conditions and combinators into .cb-rule-area, sets operators (click .condition-operator → pick), types values into .input fields, and can group selected items into brackets (putInBrackets, rule-editor.js:560).

2.5 Serializing the DOM back to JSON (rule-editor.js)

SavestoreRule() (rule-editor.js:360):

  1. getRuleItems(.cb-rule-area) (:440) walks the DOM children with class .item:
    • bracket → recurse into children (becomes a nested array);
    • function (calc-term functions) → also collect .parameter sub-items;
    • everything else → getItemMetadata(item).
  2. getItemMetadata(item) (:505) builds the condition object by reading every data-* attribute of the span (jQuery .data() camel-cases them: data-element-idquestionId), skipping jQuery-UI internals, plus every child .input value keyed by its data-data-key (:526). Numeric inputs are normalized: the locale decimal symbol → . and cast to Number (:540).
  3. If the negation select (.cb-rule-negated) is 1 and there are items, {type:'negation'} is unshifted to the front.
  4. JSON.stringify(ruleItems) → written to the parent form's hidden .data-field (:401), and a read-only HTML version is copied back into the form. The modal closes.

The rule is now just a pending change in the question/answer form; pressing the form's own Save persists #__configbox_questions.rules / #__configbox_answers.rules through the standard model store.


3. Processing — how a rule is evaluated

Engine: ConfigboxRulesHelper, which ships ionCube-encoded (helpers/encoded/). The plaintext is not part of the distribution, which is why this section documents the evaluation in the detail it does — you cannot read it out of the tree.

3.1 Public entry point

ConfigboxRulesHelper::ruleIsFollowed($jsonRule, $recordType, $recordId, $cartPositionId = NULL)

(rules.php:62)

  • Empty rule ('' / '[]') ⇒ returns true (always applies).
  • Loads the current selections for the configuration via ConfigboxConfiguration::getInstance($cartPositionId)->getSelections() (:87). These already include simulated (sim) selections transparently — critical for the consistency loop (§4.5).
  • Delegates to getEvaluationResult($jsonRule, $selections).

3.2 The core: getEvaluationResult($jsonRule, $selections) (rules.php:106)

  1. Cache key = serialize([$jsonRule, $selections]); results memoized in self::$resultCache.
  2. On first use it runs a license check (checkLicense('rules'), :113). (There's a deliberate comment about not using self:: in spots to thwart wrapper-class license bypasses; the license path itself @eval()s server-returned code at rules.php:1350 — a separate concern.)
  3. getConditions($jsonRule) = json_decode($jsonRule, true); a decode failure surfaces a system message and returns false.
  4. getConditionsCode($conditions, $selections) (§3.3) builds a PHP boolean string.
  5. $evalCode = 'return ('.$ruleCode.');'@eval($evalCode) (:139), wrapped in a ParseError catch that logs the offending eval string and rule.

3.3 Building the code: getConditionsCode($conditions, $selections) (rules.php:166)

Walks the array and concatenates a string:

  • If the first item is a negation ⇒ open with !( and close with ) at the end.
  • bracket (no type, or type=='bracket') ⇒ recurse, wrapped in ( … ).
  • combinator ⇒ append <kind> — i.e. the literal AND/OR (valid PHP operators) injected verbatim.
  • negation item itself ⇒ contributes nothing (handled by the wrapper above).
  • anything else (a real condition) ⇒ ConfigboxCondition::getCondition($type)->getEvaluationResult($condition, $selections) and append the literal true or false.

So for the §1 example the generated code is roughly:

return ( true AND ( false ) );

Security nuance: unlike the calculation engine (which injects operators and values into the eval string), the rules engine only evals the boolean skeleton — each condition is reduced to true/false in PHP first, and the comparisons (version_compare/strcmp) happen inside the condition classes, not in the eval. The only raw-injected tokens are the combinator kind and the bracket structure, both authored by an admin in the editor. It's still eval, but condition values are not an injection vector.

3.4 Rendering a rule as HTML

getRuleHtml($jsonRule, $forEditing) (:380) → getConditionsHtml() (:416) mirrors the eval walk but emits HTML: brackets → <span class="bracket item">…, combinators → getCombinatorMarkup (:444), conditions → getCondition($type)->getConditionHtml($data, $forEditing). $forEditing toggles editable .inputs vs read-only .condition-value spans.

3.5 Caching & performance

  • Per-request memoization keyed on serialize([$jsonRule, $selections]).
  • Condition instances are singletons (ConfigboxCondition::$instances, getCondition() at ConfigboxCondition.php:38).
  • Hot path warning: rules are evaluated on every selection change, once per question/answer, inside the consistency do…while loop — and QuestionProperty conditions call ConfigboxPrices::getQuestionPrice() / ConfigboxQuestion::getQuestion(). Custom types must cache aggressively (the example's getEvaluationResult docblock says exactly this).

4. The condition-type contract (ConfigboxCondition)

Every condition type is a subclass of the abstract classes/ConfigboxCondition.php.

4.1 Required (abstract) methods

MethodPurpose
getEvaluationResult($conditionData, $selections): boolThe actual test. $conditionData is the decoded JSON item (camelCase keys from the editor); $selections is [questionId => value]. Return true/false.
getConditionsPanelHtml($ruleEditorView): stringThe editor tab panel — a list of draggable available conditions for this type.
getConditionHtml($conditionData, $forEditing = true): stringThe markup for one condition, both as an editor blueprint and as the saved/displayed form. This HTML is the contract the JS serializer reads (see §5.3).

4.2 Overridable hooks (with sensible defaults)

  • getValidationErrors($conditionData): string[] — what is wrong with this condition's data, checked when the question/answer is saved (§6). The default implementation checks the operator against getOperators(); override and call parent to add your own, or override without calling parent if your type has no operators. Runs at save time, so database lookups are fine here.
  • getOperators(): array — default 6 relational operators (<,<=,==,!=,>=,>); override to restrict/rename.
  • getOperatorText($op), getTypeName(), getTypeTitle() — naming/labels (title shown on the tab).
  • showPanel(): bool — return false to exist but not show an editor tab (as Negation does).
  • containsQuestionId / containsAnswerId / containsCalculationId($data, $id): bool — let the app know the condition references an entity, so deletion of that entity can be blocked (used by ruleContainsQuestion/Answer/Calculation, rules.php:210-380).
  • getCopiedConditionData($conditionData, $copyIds): array — re-map referenced IDs when a product is copied (e.g. QuestionProperty remaps questionId/value via the $copyIds map).

4.3 The built-ins (reference implementations)

  • QuestionProperty (...ConditionQuestionProperty.php) — compares an attribute of another question: selectedAnswer.id, selected/selection, price, priceRecurring, and the selectedAnswer.assignment_custom_1..4 / option_custom_1..4 fields. Numeric values use version_compare, strings use strcmp, null handled for ==/!=. Its panel is a dedicated Kenedo view — ConfigboxViewAdminRuleeditor_questionproperty (views/adminruleeditor_questionproperty/view.html.php:4; the pre-CB4 name …_elementattribute is gone).
  • Calculation (...ConditionCalculation.php) — runs ConfigboxCalculation::calculate($calcId, …, $selections) and compares the result; remaps calcId on copy.
  • CustomerGroup (...ConditionCustomerGroup.php) — compares a field of the current customer group (ConfigboxUserHelper::getGroupData()), e.g. a custom_* group field — note this is independent of selections.
  • Negation (...ConditionNegation.php) — pseudo-type: showPanel()=false, getEvaluationResult() throws (it's never evaluated as a condition), added by the editor JS and interpreted structurally by getConditionsCode.

5. Extending the engine in the customization layer ← the important part

The rule engine is designed to be extended without touching shipped code or the encoded core. You add a new condition type as a single PHP file in the customization folder. It then appears automatically as a tab in the rule editor and is evaluated like any built-in.

5.1 Discovery mechanism

ConfigboxCondition scans two directories (ConfigboxCondition.php:74 and :104):

  1. …/com_configbox/classes/rule_condition_types (shipped types)
  2. KenedoPlatform::p()->getDirCustomization().'/rule_condition_types' (your custom types)

On Joomla, getDirCustomization() = …/components/com_configbox/data/customization (platforms/joomla/general.php:995). So custom condition files go in:

docroot/components/com_configbox/data/customization/rule_condition_types/

(data/ is gitignored and survives upgrades — the intended place for customizations. Other parallel extension dirs exist: data/customization/templates, …/model_property_customization, …/custom_observers, etc.)

  • loadConditionClasses() include_onces every *.php in both dirs.
  • getConditionClassNames() / getConditionTypeNames() derive type names by stripping the ConfigboxCondition / CustomCondition prefix from the class/file name.
  • getCondition($type) resolves the class by trying ConfigboxCondition<Type> first, then CustomCondition<Type> (ConfigboxCondition.php:46-58).

5.2 Naming rules (must follow exactly)

  • File: data/customization/rule_condition_types/CustomCondition<Type>.php
  • Class: class CustomCondition<Type> extends ConfigboxCondition
  • The <Type> (e.g. Inventory) becomes the type name stored as "type":"Inventory" in the JSON and shown on the editor tab.
  • You cannot override a built-in by reusing its type name: getCondition() finds ConfigboxCondition<Type> first, so CustomConditionQuestionProperty would be shadowed. Pick a new type name. (To truly replace a built-in you'd have to edit the shipped file — not upgrade-safe.)

5.3 What you implement

Copy classes/rule_condition_types/CustomConditionExample.php as your starting point. Implement:

  1. getConditionHtml($conditionData, $forEditing) — emit the condition span. The data contract the serializer depends on:

    • Root element: <span class="item condition" data-type="<Type>" data-operator="==" …>.
    • Every data-* attribute becomes a JSON key (kebab → camelCase). data-type and data-operator are required.
    • For user-entered values, put <input class="input" data-data-key="someKey" …> as a direct child of span.item.condition. Its value lands in $conditionData['someKey']. Use camelCase for data-data-key. Numbers are auto-normalized for the locale decimal symbol.
    • Provide .condition-name (what the user sees) and .condition-operator ($this->getOperatorText($op)); the operator picker is wired automatically.
    • Respect $forEditing: editable .input when true, read-only .condition-value when false.
  2. getConditionsPanelHtml($ruleEditorView) — return the editor tab's panel: a <ul> of getConditionHtml($blueprint) items the user drags into the rule. Each blueprint is an associative array with at least type, operator, and your data keys (see the example's $availableConditions).

  3. getEvaluationResult($conditionData, $selections) — read your keys out of $conditionData, compute the truth value, return bool. $selections is [questionId => value]; for customer data use ConfigboxUserHelper::getUser(). Cache — this runs many times per selection change.

Optionally override getTypeTitle() (tab label), getOperators() (custom operator set), getValidationErrors() (§6), showPanel(), the contains*Id() methods (so referenced entities can't be deleted out from under the rule), and getCopiedConditionData() (ID remapping on product copy).

5.4 Minimal example

data/customization/rule_condition_types/CustomConditionStockLevel.php:

<?php
defined('CB_VALID_ENTRY') or die();

class CustomConditionStockLevel extends ConfigboxCondition {

function getTypeTitle() { return KText::_('Stock level'); }

// One draggable blueprint in the editor tab
function getConditionsPanelHtml($ruleEditorView) {
$blueprint = ['type'=>'StockLevel', 'sku'=>'', 'operator'=>'>=', 'threshold'=>''];
return '<ul class="conditions-list"><li>'
. $this->getConditionHtml($blueprint)
. '</li></ul>';
}

function getConditionHtml($conditionData, $forEditing = true) {
ob_start(); ?>
<span class="item condition"
data-type="<?php echo hsc($conditionData['type']); ?>"
data-sku="<?php echo hsc($conditionData['sku']); ?>"
data-operator="<?php echo hsc($conditionData['operator']); ?>">
<span class="condition-name"><?php echo KText::_('Stock for SKU'); ?></span>
<span class="condition-operator"><?php echo $this->getOperatorText($conditionData['operator']); ?></span>
<?php if ($forEditing) { ?>
<input class="input" data-data-key="sku" type="text" value="<?php echo hsc($conditionData['sku']); ?>" />
<input class="input" data-data-key="threshold" type="text" value="<?php echo hsc($conditionData['threshold'] ?? ''); ?>" />
<?php } else { ?>
<span class="condition-value"><?php echo hsc($conditionData['sku'].' '.($conditionData['threshold'] ?? '')); ?></span>
<?php } ?>
</span>
<?php return ob_get_clean();
}

function getEvaluationResult($conditionData, $selections) {
$stock = MyInventory::getStock($conditionData['sku']); // your data source, cached
return version_compare((float)$stock, (float)$conditionData['threshold'], $conditionData['operator']);
}
}

Drop the file in, reload a question/answer edit form, open the rule editor — a "Stock level" tab appears with a draggable condition; saving stores {"type":"StockLevel","sku":"…","operator":">=","threshold":5} in the rules column, and the engine evaluates it on every selection change.

5.5 Gotchas when extending

  • Class loads twice safely (include_once), but a fatal/parse error in your file breaks the whole admin — develop carefully.
  • Type name = class suffix, and it must round-trip through the JSON; keep it alphanumeric.
  • Performance: getEvaluationResult is on the per-keystroke hot path; memoize external lookups.
  • No effects in the condition — a condition only returns true/false. To do something on a selection (auto-select, require, deselect), that's the configurator/consistency engine (getInconsistencies / getAutoSelectItems, rules.php:632/1016) plus the question's behavior_on_activation / behavior_on_changes / behavior_on_inconsistency settings — not the rule type.
  • i18n: title via getTypeTitle()/CONDITION_TYPE_<Name> language key; UI strings via KText::_.
  • Other customization extension points follow the same pattern: custom question types (data/customization/classes/question_types / ConfigboxQuestion<Type>), custom calc term types (calc_term_types/), and uploadable observers (admin Connectors) — the rule condition mechanism is one instance of a consistent "drop a prefixed class in data/customization" convention.

6. Validation on save

The editor is a drag and drop surface with no validation of its own (storeRule() serializes whatever is in the drop zone), and the JSON is stored verbatim. What the engine then does with it is build a PHP boolean expression and eval() it (§3.3), so a rule that does not hold together is not a rule that behaves oddly — it is a ParseError, a thrown exception in the admin, or a question that silently never shows.

KenedoPropertyRule::check() refuses those on the way in. It is the standard Kenedo property validation hook, so it covers every write: the admin form (KenedoController::store()) and the MCP authoring tools (ConfigboxMcpHelper::storeData()) both call KenedoModel::validateData(). Failures come back as a 422, attributed to the rules field under validationIssuescheck() reports through the property's own error store, which validateData() turns into a KenedoValidationException carrying one issue per bad field.

6.1 What is checked

Structure — each of these produces PHP that does not parse:

RuleEvaluated code
two conditions with no combinatorreturn ( true true );
leading or trailing combinatorreturn ( AND true );
two combinators in a rowreturn ( true AND OR false );
an empty groupreturn ( true AND () );
nothing but a negation markerreturn (!( ));

Also refused: a negation marker anywhere but the very first item of the whole rule (elsewhere it contributes nothing, so the rule means something other than what was authored); an item that is neither a condition, a group nor a combinator (the engine recurses into anything without a type key and makes () of it); and a combinator whose kind is not AND/OR in any case — that value is injected into the eval'ed string verbatim, so it is whitelisted rather than escaped.

References and vocabulary — these store and evaluate without complaint, which is why nobody notices them:

  • a condition type with no class behind it — ConfigboxCondition::getCondition() throws on it, and getRuleHtml() reaches the same call, so storing one breaks every list that shows the rule;
  • an operator the type does not have — getOperatorText() throws the same way;
  • a field that is not one of getQuestionProperties() (rules written before the CB4 rename say selectedOption.id and are in exactly this state);
  • a question, answer or calculation that does not exist — a rule that can never be satisfied, and in the calculation's case a fatal error in the list view, since getConditionHtml() reads ->name off whatever the cache hands back.

Per-type checks live in the condition classes (getValidationErrors(), §4.2), so a custom type gets the structural and operator checks for free and can add its own.

6.2 What is deliberately not checked

  • A rule that is already stored. Both write paths seed the request from the existing record, so an untouched rule is posted back on every partial update. Validating it would let a rule written by an older editor block edits to fields that have nothing to do with it. check() compares against the stored column first and only judges a rule that this save actually changes. The consequence: broken rules already in the wild surface only when someone edits them — finding them all is a separate audit, not this gate.
  • Whether the rule can ever be true. A condition on a question of another product is well-formed and unsatisfiable; the MCP encoder rejects that on its own surface (ConfigboxMcpRulesHelper), the property does not.
  • Anything in the browser. The editor still lets the user build a broken rule and closes its modal; the message arrives when the form is saved.

6.3 Two rules for anything added here

  • Never throw. Validation reaches into condition classes, third-party ones included. Every call into one is wrapped — a fatal error during validation is worse than the broken rule it was meant to catch.
  • Never read through the cache. ConfigboxCacheHelper is APCu-backed for the web process and file-backed on CLI, so an authoring tool would get a stale answer and refuse a rule that is perfectly valid on a freshly seeded catalog. The existence checks query the tables directly.

Covered by tests/specs/backend/rule-validation.spec.ts.