Developer guide
How does Smart Add-Ons safe formula evaluation avoid eval()?
A formula string never reaches PHP's eval() at all. It first has to pass a regex whitelist that rejects anything except digits, a decimal point, and + – * / ( ) — no letters, no function calls, no semicolons — and only what survives that check gets tokenized and walked by a small hand-written recursive-descent parser that encodes operator precedence structurally. Anything that fails to parse falls back to a price of zero rather than throwing, so a broken formula can't break checkout.
Smart Add-Ons safe formula evaluation exists because an admin-entered pricing formula is, by definition, untrusted-ish input reaching server-side code — even from a trusted admin, running it through PHP’s own eval() would mean any typo or malicious paste executes as real PHP. This is how the actual evaluator avoids that, read straight from FormulaEvaluator.php.

The whitelist that runs before anything else
Placeholders like {base}, {qty}, and {field:123} are substituted with their numeric values first, and only after that substitution does the resulting string have to pass one check before evaluation is even attempted:
if ( $expression === '' || ! preg_match( '/^[\d\.\+\-\*\/\(\)]+$/', $expression ) ) {
return 0.0;
}
That pattern allows exactly digits, a decimal point, and the five arithmetic/grouping characters — nothing else. No letters means no function calls and no constant names; no semicolons or backticks means no statement injection. A formula that somehow still contained a stray placeholder that didn’t resolve, or literally anything else, simply never reaches the parser at all.
Smart Add-Ons safe formula evaluation, the parser itself
What’s left is walked by a small recursive-descent parser — three functions, each one precedence level, calling into the next:
parseExpression() // handles + and -, calls parseTerm() for each operand
parseTerm() // handles * and /, calls parseFactor() for each operand
parseFactor() // handles numbers and ( parseExpression() )
Precedence isn’t a lookup table here — it’s the call structure itself. parseExpression only ever combines results from parseTerm, which means every * and / nested inside has already been fully resolved before the surrounding +/- ever sees it, without any explicit “multiplication binds tighter” rule written down anywhere. Parentheses recurse straight back into parseExpression, which is what lets them nest to any depth for free.
Failure mode: silently free, never a broken checkout
Two separate fail-safes sit around the actual math. Division by zero doesn’t throw:
$value = $operator === '*' ? $value * $right : ( $right == 0.0 ? 0.0 : $value / $right );
And any other parse failure — an unexpected token, a missing closing paren — is caught at the top level and also resolves to zero:
try {
return round( $this->evaluateExpression( $expression ), 2 );
} catch ( \Throwable ) {
return 0.0;
}
That’s a deliberate trade-off: a malformed formula prices the add-on at zero instead of throwing a fatal error at checkout. Free is a safer failure than broken for a live store — a shopper could complete a purchase with an underpriced add-on before anyone notices, but nobody gets a white-screen error at the worst possible moment. It’s a real limitation to know about, not a hidden gotcha: a formula bug shows up as “why did this add-on charge nothing” during a manual review, not as an error log entry pointing straight at the broken formula.
Common questions
Could Smart Add-Ons safe formula evaluation support functions like round() or min() in the future?
Not without changing the whitelist and adding function-call parsing to parseFactor() — both real, bounded changes, but the current implementation doesn’t support it. A formula today is arithmetic only: numbers, the four operators, and parentheses, which is the entire scope Smart Add-Ons safe formula evaluation was built to cover.
Does the whitelist regex run against the raw admin input or the substituted expression?
The substituted expression — placeholders like {field:123} are replaced with their numeric values first, and the regex check runs on what’s left. That ordering matters: the raw formula is allowed to contain letters (inside placeholder names), but nothing that survives substitution is.
What happens to an unrecognized placeholder like {shipping}?
replaceVariables() looks up each placeholder name against the variables map it was given, and substitutes 0 for anything not found rather than leaving the literal placeholder text in place. That’s what keeps the whitelist check meaningful — a formula can’t accidentally smuggle letters through to the parser just by referencing a variable name that doesn’t exist.
For the store-owner-facing guide to writing formulas and the placeholders available, see the formula pricing guide, and the recursive-descent parser (opens in new tab) pattern this evaluator is built on.
Comments