The Rule Engine, in Detail
- Version
- 3.x
- Updated
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 —
QuestionPropertyconditions,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 inmigration-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, propertytype=>'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.php → KenedoPropertyRule). 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:
| Item | Shape |
|---|---|
| condition | { "type": "<TypeName>", "operator": "==", ...data } |
| combinator | { "type": "combinator", "kind": "AND" } (or "OR") |
| bracket | a 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 viaconfigbox/ruleEditor::initRuleEditor, seeview.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-fieldholding the current JSON, decorated withdata-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):
- Reads
rule,productId,pageId,usageInfrom the request. - Renders the current rule into editable HTML:
ConfigboxRulesHelper::getRuleHtml($rule)(§3.4). - Picks the heading text by context ("Show the question if…" vs "Show the answer if…") and the negated variants.
- Enumerates all condition types —
ConfigboxCondition::getConditionTypeNames()(§5.1) — and for each whoseshowPanel()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 theif ($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-areadrop zone (the rule being built) —tmpl/default.php:29. #combinator-blueprints: draggableAND/ORspans (<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)
Save → storeRule() (rule-editor.js:360):
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.parametersub-items;- everything else →
getItemMetadata(item).
getItemMetadata(item)(:505) builds the condition object by reading everydata-*attribute of the span (jQuery.data()camel-cases them:data-element-id→questionId), skipping jQuery-UI internals, plus every child.inputvalue keyed by itsdata-data-key(:526). Numeric inputs are normalized: the locale decimal symbol →.and cast toNumber(:540).- If the negation select (
.cb-rule-negated) is1and there are items,{type:'negation'}is unshifted to the front. 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 (
''/'[]') ⇒ returnstrue(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)
- Cache key =
serialize([$jsonRule, $selections]); results memoized inself::$resultCache. - On first use it runs a license check (
checkLicense('rules'),:113). (There's a deliberate comment about not usingself::in spots to thwart wrapper-class license bypasses; the license path itself@eval()s server-returned code atrules.php:1350— a separate concern.) getConditions($jsonRule)=json_decode($jsonRule, true); a decode failure surfaces a system message and returnsfalse.getConditionsCode($conditions, $selections)(§3.3) builds a PHP boolean string.$evalCode = 'return ('.$ruleCode.');'→@eval($evalCode)(:139), wrapped in aParseErrorcatch 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, ortype=='bracket') ⇒ recurse, wrapped in( … ). - combinator ⇒ append
<kind>— i.e. the literalAND/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 literaltrueorfalse.
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/falsein PHP first, and the comparisons (version_compare/strcmp) happen inside the condition classes, not in the eval. The only raw-injected tokens are the combinatorkindand the bracket structure, both authored by an admin in the editor. It's stilleval, 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()atConfigboxCondition.php:38). - Hot path warning: rules are evaluated on every selection change, once per
question/answer, inside the consistency
do…whileloop — andQuestionPropertyconditions callConfigboxPrices::getQuestionPrice()/ConfigboxQuestion::getQuestion(). Custom types must cache aggressively (the example'sgetEvaluationResultdocblock 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
| Method | Purpose |
|---|---|
getEvaluationResult($conditionData, $selections): bool | The actual test. $conditionData is the decoded JSON item (camelCase keys from the editor); $selections is [questionId => value]. Return true/false. |
getConditionsPanelHtml($ruleEditorView): string | The editor tab panel — a list of draggable available conditions for this type. |
getConditionHtml($conditionData, $forEditing = true): string | The 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 againstgetOperators(); override and callparentto add your own, or override without callingparentif 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— returnfalseto exist but not show an editor tab (asNegationdoes).containsQuestionId / containsAnswerId / containsCalculationId($data, $id): bool— let the app know the condition references an entity, so deletion of that entity can be blocked (used byruleContainsQuestion/Answer/Calculation,rules.php:210-380).getCopiedConditionData($conditionData, $copyIds): array— re-map referenced IDs when a product is copied (e.g.QuestionPropertyremapsquestionId/valuevia the$copyIdsmap).
4.3 The built-ins (reference implementations)
QuestionProperty(...ConditionQuestionProperty.php) — compares an attribute of another question:selectedAnswer.id,selected/selection,price,priceRecurring, and theselectedAnswer.assignment_custom_1..4/option_custom_1..4fields. Numeric values useversion_compare, strings usestrcmp, null handled for==/!=. Its panel is a dedicated Kenedo view —ConfigboxViewAdminRuleeditor_questionproperty(views/adminruleeditor_questionproperty/view.html.php:4; the pre-CB4 name…_elementattributeis gone).Calculation(...ConditionCalculation.php) — runsConfigboxCalculation::calculate($calcId, …, $selections)and compares the result; remapscalcIdon copy.CustomerGroup(...ConditionCustomerGroup.php) — compares a field of the current customer group (ConfigboxUserHelper::getGroupData()), e.g. acustom_*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 bygetConditionsCode.
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):
…/com_configbox/classes/rule_condition_types(shipped types)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*.phpin both dirs.getConditionClassNames()/getConditionTypeNames()derive type names by stripping theConfigboxCondition/CustomConditionprefix from the class/file name.getCondition($type)resolves the class by tryingConfigboxCondition<Type>first, thenCustomCondition<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()findsConfigboxCondition<Type>first, soCustomConditionQuestionPropertywould 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:
-
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-typeanddata-operatorare required. - For user-entered values, put
<input class="input" data-data-key="someKey" …>as a direct child ofspan.item.condition. Its value lands in$conditionData['someKey']. Use camelCase fordata-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.inputwhen true, read-only.condition-valuewhen false.
- Root element:
-
getConditionsPanelHtml($ruleEditorView)— return the editor tab's panel: a<ul>ofgetConditionHtml($blueprint)items the user drags into the rule. Each blueprint is an associative array with at leasttype,operator, and your data keys (see the example's$availableConditions). -
getEvaluationResult($conditionData, $selections)— read your keys out of$conditionData, compute the truth value, returnbool.$selectionsis[questionId => value]; for customer data useConfigboxUserHelper::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:
getEvaluationResultis 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'sbehavior_on_activation/behavior_on_changes/behavior_on_inconsistencysettings — not the rule type. - i18n: title via
getTypeTitle()/CONDITION_TYPE_<Name>language key; UI strings viaKText::_. - 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 indata/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 validationIssues — check()
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:
| Rule | Evaluated code |
|---|---|
| two conditions with no combinator | return ( true true ); |
| leading or trailing combinator | return ( AND true ); |
| two combinators in a row | return ( true AND OR false ); |
| an empty group | return ( true AND () ); |
| nothing but a negation marker | return (!( )); |
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
typewith no class behind it —ConfigboxCondition::getCondition()throws on it, andgetRuleHtml()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
fieldthat is not one ofgetQuestionProperties()(rules written before the CB4 rename sayselectedOption.idand 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->nameoff 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.
ConfigboxCacheHelperis 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.