The Rule Engine, in Detail
- Version
- 4.0 preview
- Updated
Scope: how conditional logic ("rules") is created, stored, processed, and — most importantly — extended in the customization layer · Last reviewed: 2026-09-06
Naming: this page uses the 4.0 (CB4) vocabulary —
QuestionPropertyconditions,questionId, and thefieldkeys a question type declares (selected,price,w, …). 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. Migration 3.8.26 then unified the field names (selectedAnswer.id/selectionbecameselected); see §1.
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 (the one
condition about a question, generic over every question type through the fields the type declares,
§4.3), Calculation, CustomerGroup, and the internal Negation. The documented template for a
custom type lives in docs/customization/examples/CustomConditionExample.php; it no longer ships in
classes/, where it rendered as a live "Custom Conditions" entry in every install.
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": "selected", "operator": "==", "value": "22" },
{ "type": "combinator", "kind": "AND" },
[
{ "type": "QuestionProperty", "questionId": 8, "field": "selected", "operator": ">=", "value": 5 }
]
]
The same field key names the selection on both questions; what it means (an answer id on question
7, a number on the slider 8) is decided by the question's type, not by the condition (§4.3). A field
declared once per answer carries answerId too:
{ "type": "QuestionProperty", "questionId": 42, "field": "quantity", "answerId": 57, "operator": ">=", "value": "3" }.
A negated version prepends { "type": "negation" } to the array. An empty rule is '' or []
(meaning "always applies").
One vocabulary: migration 3.8.26
Rules used to name the selection three ways (selectedAnswer.id, selection, selected), formula
terms named the field fieldPath where rules said field, and pre-3.6 data nested the keys under a
data wrapper. Since the question types declare their fields once, the stored data was brought onto
that one vocabulary: helpers/updates/3.8.26.php rewrites every question rule, answer rule and
formula through ConfigboxRulesJsonHelper::normalize() (helpers/rulesjson.php, the one walker over
a rule or formula tree, brackets and function parameters included). selectedAnswer.id / selection
become selected, fieldPath becomes field, questionId becomes an int, the data wrapper is
flattened. Idempotent; a value that is not valid JSON is logged and left alone. The transfer import
normalizes older packages the same way. The type name QuestionProperty is unchanged: it is
persisted data and the calc-code DSL's function name.
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/editor-picker.css(the question-first picker, shared with the formula editor) +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 picker (server side)
ConfigboxViewAdminRuleeditor::prepareTemplateVars() (view.html.php:125):
- 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.
prepareQuestions()(:166): loads the product's questions in page and question order (ConfigboxRulesHelper::getQuestions($productId)) and, for each one the cache knows, asks the generic question condition for the chips that question offers (ConfigboxConditionQuestionProperty::getQuestionConditionsHtml(), §4.3): one group per field group the question's type declares. A product with more than ten questions also gets a page filter and a title filter.prepareOtherConditions()(:214): enumerates the condition types (ConfigboxCondition::getConditionTypeNames(), §5.1) in a fixed order for the stock ones (Calculation,CustomerGroup) and then whatever the site added, skips a type whoseshowPanel()is false or whose panel is empty, and keeps title (getTypeTitle()) and panel (getConditionsPanelHtml($this)) per type. These become the entries under "Other conditions".
The earlier note about a latent "tab ordering" bug is obsolete: there are no tabs, and the order of the non-question entries is the explicit list in
prepareOtherConditions().
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(tmpl/default.php:44), the question-first picker shared with the formula editor (assets/css/editor-picker.css): on the left a.cb-editor-question-listwith one<li>per question (data-group="question-<id>"; the page filter and#question-filterabove it when the product is long), then the "Other conditions".cb-editor-other-listwith one<li>per non-question type (data-group="type-<Type>"). On the right.cb-editor-groups, one.cb-editor-groupper question and per type (#cb-editor-group-question-<id>,#cb-editor-group-type-<Type>), each holding the draggable<span class="item condition" …>chips; clicking an entry on the left shows its group and hides the others (rule-editor.js:325).- A
#cb-editor-operator-picker-blueprint(full = 6 operators, short = is/is-not,tmpl/default.php:99) shown when the user clicks a condition's operator; a chip carryingcb-rule-condition--short-operators(a choice or a text field) gets the short one (rule-editor.js:235). - A negation
<select class="cb-rule-negated">(Show-if vs Hide-if,tmpl/default.php:23). - Buttons: Put-in-brackets, Remove-selected, Limit-condition-width, 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 what the
serializer reads.
The user drags conditions and combinators into .cb-rule-area, sets operators (click
.cb-rule-condition-operator → pick, rule-editor.js:226), types values into .input fields, and
can group selected items into brackets (putInBrackets, rule-editor.js:544).
2.5 Serializing the DOM back to JSON (rule-editor.js)
Save → storeRule() (rule-editor.js:344):
getRuleItems(.cb-rule-area)(:424) 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)(:489) builds the condition object by reading everydata-*attribute of the span (jQuery.data()camel-cases them:data-question-id→questionId,data-field→field,data-answer-id→answerId), skipping jQuery-UI internals, plus every child.inputvalue keyed by itsdata-data-key. Numeric inputs are normalized: the locale decimal symbol →.and cast toNumber. There is no per-type code in the serializer; the chip markup IS the stored shape.- 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, 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 callConfigboxQuestion::getQuestion()and read the field throughgetFieldValue(), which for the price fields reachesConfigboxPrices. Custom types must cache aggressively (the example'sgetStock()comment says exactly this), and so must a custom question type'sgetFieldValue().
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 type's panel in the editor's picker: a <ul class="cb-rule-conditions-list"> of draggable available conditions, shown when the type's entry under "Other conditions" is clicked. |
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 (the title is the type's entry under "Other conditions").showPanel(): bool— returnfalseto exist but get no entry in the picker (asNegationdoes, and asQuestionPropertydoes: its chips are listed per question instead).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,answerIdand, for theselectedfield of an answer-taking question,valuevia the$copyIdsmap).
4.3 The built-ins (reference implementations)
QuestionProperty(...ConditionQuestionProperty.php) — compares a field of a question with a value, and is generic over question types: it knows nothing about them. What a question offers is declared by its class (ConfigboxQuestion::getRuleFields()), how a value is read isgetFieldValue(), how it compares isConfigboxQuestionField::matches(); this class only wires the declaration to the engine. Stored shape:{"type":"QuestionProperty","questionId":42,"field":"selected","operator":"==","value":"57"}, plusanswerIdfor a field declared once per answer ({…,"field":"quantity","answerId":57,…}). Base fields every type has:selected(the answer for a question with answers, the entry otherwise),price,priceRecurring,weight,question_custom_1..4and, with answers,selectedAnswer.assignment_custom_1..4/selectedAnswer.option_custom_1..4; a type adds its own (w,area,quantity, …). Comparison is per field kind: numbers numerically (version_compareis gone;2.5 >= 2.10is true), dates by day, choices and texts as strings with==/!=only; an empty condition value means "not answered", and an unanswered question isnull, never0. Every method that reads a stored condition runs it throughConfigboxRulesJsonHelper::normalizeItem()first (normalized()), so a condition still sayingselectedAnswer.idorselection— from a table no migration knows, or a writer that still emits the old names — evaluates asselected. It has no panel (showPanel()false,:37): the editor lists its chips under each question viagetQuestionConditionsHtml()(:174), grouped by the field'sgroup.getValidationErrors()(:235) refuses a missing question, a field the type does not declare, an operator the field cannot take, a value of the wrong kind, and an answer that is not the question's.remapReferences()(:379) remapsquestionId,answerIdand thevalueofselectedon an answer-taking question, for product copy and transfer alike. The per-type sub-viewviews/adminruleeditor_questionproperty/is gone. The runtime API'sexplainQuestionprojection labels blockers through the same field declaration (ConfigboxConfiguratorApiHelper::projectCondition(),helpers/configuratorapi.php:1345:fieldTitle,valueLabel,currentValueLabel).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, and the extension point depends on what the condition is about:
- A condition about a question (its answer, its entry, a part of a composite selection such as
the width of a dimensions question) is not a condition type. The question type declares it as
a field in
getRuleFields()and reads it ingetFieldValue(); the genericQuestionPropertycondition (§4.3) does the chips, the evaluation, the validation, the delete guards and the id remapping. Seecustomization/com_configbox_custom_question_types.md, §2.6 "Rules and formulas — the type declares its fields". Nothing in this section is needed for it. - A condition about anything else (a stock level, the weekday, external data) is a condition
type: a single PHP file in the customization folder. It then appears automatically as an entry
under "Other conditions" in the rule editor and is evaluated like any built-in. That is what the
rest of §5 covers; the full guide is
customization/com_configbox_custom_rule_conditions.md.
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; the entry under "Other conditions" showsgetTypeTitle(). - 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 docs/customization/examples/CustomConditionExample.php (a stock-level condition, class
CustomConditionStockLevel) 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), so everydata-*attribute on the chip is a stored key.data-typeanddata-operatorare required. - For user-entered values, put
<input class="input" data-data-key="someKey" …>insidespan.item.condition. Its value lands in$conditionData['someKey']. Use camelCase fordata-data-key. Numbers are auto-normalized for the locale decimal symbol. - Provide
.cb-rule-condition-name(what the user sees) and.cb-rule-condition-operator($this->getOperatorText($op)); the operator picker binds to the operator span automatically. - Respect
$forEditing: editable.inputwhen true, read-only.cb-rule-condition-valuewhen false.
- Root element:
-
getConditionsPanelHtml($ruleEditorView)— return the type's panel: a<ul class="cb-rule-conditions-list">of<li>wrappinggetConditionHtml($blueprint)items the user drags into the rule. It is shown when the type's entry under "Other conditions" is clicked. Each blueprint is an associative array with at leasttype,operator, and your data keys (the example builds one per SKU). -
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() (the entry's 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 (the shipped example is the
same idea with comments and validation):
<?php
defined('CB_VALID_ENTRY') or die();
class CustomConditionStockLevel extends ConfigboxCondition {
function getTypeTitle() { return KText::_('Stock level'); }
// The panel under "Other conditions": one draggable blueprint
function getConditionsPanelHtml($ruleEditorView) {
$blueprint = ['type'=>'StockLevel', 'sku'=>'ABC-100', 'operator'=>'>=', 'value'=>''];
return '<ul class="cb-rule-conditions-list"><li>'
. $this->getConditionHtml($blueprint)
. '</li></ul>';
}
function getConditionHtml($conditionData, $forEditing = true) {
ob_start(); ?>
<span class="item condition stocklevel"
data-type="StockLevel"
data-sku="<?php echo hsc($conditionData['sku']); ?>"
data-operator="<?php echo hsc($conditionData['operator']); ?>">
<span class="cb-rule-condition-name"><?php echo hsc(KText::sprintf('Stock of %s', $conditionData['sku'])); ?></span>
<span class="cb-rule-condition-operator"><?php echo $this->getOperatorText($conditionData['operator']); ?></span>
<?php if ($forEditing) { ?>
<input class="input" data-data-key="value" type="text" value="<?php echo hsc($conditionData['value'] ?? ''); ?>" />
<?php } else { ?>
<span class="cb-rule-condition-value"><?php echo hsc($conditionData['value'] ?? ''); ?></span>
<?php } ?>
</span>
<?php return ob_get_clean();
}
function getEvaluationResult($conditionData, $selections) {
$stock = MyInventory::getStock($conditionData['sku']); // your data source, cached
return ConfigboxQuestionField::compare((float) $stock, (float) $conditionData['value'], $conditionData['operator']);
}
}
Drop the file in, reload a question/answer edit form, open the rule editor: a "Stock level"
entry appears under "Other conditions" with a draggable condition; saving stores
{"type":"StockLevel","sku":"ABC-100","operator":">=","value":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::_. - A condition about a question does not belong here. Declare a field on the question type
(
data/customization/question_types/ConfigboxQuestion<Type>.php,getRuleFields()). The old pattern of a per-type condition class reading asubValueis gone; the core migration 3.8.26 unifies theQuestionPropertyvocabulary, and a site that had such classes rewrites their stored conditions toQuestionPropertywith afieldin its own migration track. - Other customization extension points follow the same pattern: custom question types
(
data/customization/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
fieldthe question's type does not declare (findRuleField()), an operator the field's kind cannot take, or a value of the wrong kind (rules written before the CB4 rename sayselectedOption.idand are in exactly this state; the pre-3.8.26 spellingsselectedAnswer.id/selectionwere rewritten toselectedby migration 3.8.26, and the MCP encoder still maps them on the way in); - 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.