Skip to content

Expressions And Math

Mikhail Deynekin edited this page Oct 11, 2026 · 1 revision

Expressions & Math

Note

New in v5.0.0. Canonical spec: docs/TEMPLATE-SYNTAX.md Β§1/Β§4 Β· threat model: docs/SECURITY.md Β§4. Runnable tour: examples/05-expressions.php.

Templates can compute. This page covers the three v5 additions β€” inline expressions, nested/dynamic variables and math filters β€” and the security model that makes them safe for untrusted template authors.

Contents

  1. Computing a percentage (the 30-second version)
  2. Inline expressions %{= …}
  3. Nested variables: indexing
  4. Nested variables: get() indirection
  5. Math reference
  6. Security model
  7. Configuration
  8. Linting
  9. Pitfalls

Computing a percentage (the 30-second version)

Given ['done' => 37, 'total' => 250]:

<!-- inline expression β€” the recommended form -->
Progress: %{= done / total * 100 | round:1 }%          β†’ Progress: 14.8%

<!-- or the pct filter with a LITERAL total -->
Progress: %{done|pct:250:1}%                           β†’ Progress: 14.8%

<!-- 4.x style still works -->
<!--@Set share="done / total * 100"-->
Progress: %{share|round:1}%

Division by zero is not an error: done / 0 yields 0 (html4.pl semantics), so a zero total renders 0% instead of a warning or a blank page.

Inline expressions %{= …}

Any substitution tag accepts an expression after an = prefix:

%{= EXPR |filter:arg|filter2 }
#{= EXPR }                      <!-- alternate braces -->
{= EXPR }                       <!-- only with syntax.single_braces -->
  • The same evaluator as @If/@Set β€” a closed interpreter: tokenizer β†’ AST β†’ reduction against the render scope. No eval(), no dynamic function calls, no property writes, no file/network access.
  • Results flow through the normal pipeline: filter chain, then auto-escaping. %{= msg } with msg = '<b>' renders &lt;b&gt;.
  • Filter chains split at the first top-level |, quote- and bracket-aware: %{= "a|b" | upper} β†’ A|B.
  • Missing names are null (coerced to 0 in arithmetic, '' on output) β€” consistent with @If/@Set. The missing_var policies apply to plain-name substitutions only.
  • Errors stay visible: a malformed expression keeps the tag in the output ([%{= 1 + }]) and records one expr-invalid diagnostic; with syntax.strict: true it throws instead.

The = prefix is mandatory by design: %{a-b} remains a lookup of the variable literally named a-b (names may contain - since v3), so no existing template can be reinterpreted as arithmetic.

Nested variables: indexing

Bracket access with a key computed at render time β€” the "hash element by key" case:

%{= prices[currency] }           β†’ 12.5     (currency = 'EUR', data-driven key)
%{= rows[i].name }               β†’ Second   (index from a variable, then a property)
%{= hash['x-1 y'] }              β†’ keys a dot path cannot express
%{= matrix[i][j] }               β†’ chained indexes
%{= name[-1] }                   β†’ last character (UTF-8 aware, PHP-style negatives)
<!--@If cart[userId].count > 3-->…<!--@EndIf-->

What a dynamic key may touch (Scope::index()):

Container Behaviour
array array_key_exists semantics (numeric-string keys follow PHP rules)
ArrayAccess offsetExists / offsetGet
string character offset, UTF-8 aware, negative from the end
plain object never β€” properties/methods stay reachable only through author-written dotted paths

Keys must be int or string (≀ 256 chars, no null bytes). Arrays, objects or null as a key are a miss. Every miss is null β€” never a PHP warning, never a fatal.

Nested variables: get() indirection

When the name itself is data β€” a variable inside a variable:

<!--@Set fieldName="user.email"-->
%{= get(fieldName) }                 β†’ a@b.c
%{= get(prefix ~ suffix, 'β€”') }      β†’ composed names, with a default
%{= get('rows[1].name') }            β†’ dot paths and literal bracket keys
%{= get("hash['x-1 y']") }           β†’ quoted keys for awkward literals

<!--@Loop fields-->                  <!-- fields = ['name', 'email'] -->
    %{= get('user.' ~ value) }       <!-- classic: dynamic column render -->
<!--@EndLoop-->

get(name[, default]) parses its argument with a strict path grammar (dotted word runs + integer or quoted bracket keys) and resolves it only against the scope stack. Malformed or hostile paths β€” _SERVER, ../../etc/passwd, a[, a..b, null bytes, > 256 chars β€” resolve to the default. There is no global, constant, environment or filesystem fallback for it to find.

Math reference

Operators (in expressions)

Arithmetic + - * / % (Γ·0 and mod-0 β†’ 0) Β· unary - +
Concat ~
Compare == != === !== < <= > >= Β· in Β· matches
Logic &&/and Β· ||/or Β· !/not
Choose ?: Β· ??
Ranges a..b inclusive integers (capped at 100 000), indexable: (1..3)[1]

Functions (whitelist, 23 total)

count length empty defined min max abs round floor ceil int float str lower upper number default β€” plus, new in v5:

Function Semantics Guards
pow(a, b) exponentiation INF/NAN β†’ 0; pow(0, -1) β†’ 0
sqrt(x) square root negative β†’ 0
sum(v) sum of array/iterable/scalar 100 000-item iteration budget
avg(v) mean empty β†’ 0
clamp(v, lo, hi) bound to a range swapped bounds are corrected
get(name[, def]) indirect lookup strict path grammar, scope-only

Filters (arguments are literals!)

Filter Example Result for v = 37
add:n sub:n mul:n %{v|mul:2} 74
div:n mod:n %{v|mod:10} 7
pow:n sqrt %{v|pow:2} 1369
pct:total[:dec] %{v|pct:250:1} 14.8
sum avg %{scores|avg|round:1} mean of a list
clamp:min:max %{v|clamp:0:30} 30

Numeric arguments parse engine-wide (1 234,5 = 1234.5); zero divisors and overflow degrade to 0.

Security model

The design question: what does a template author gain with v5 that they did not have in v4? Nothing. Every inline expression is computable in 4.x via <!--@Set v="EXPR"-->%{v} β€” v5 removes the two-tag detour, not a boundary. The evaluator was already the security-critical component, and it stays a closed interpreter:

  • No eval(), no dynamic calls, no I/O β€” enforced by a token-level test over every source file in CI (13_security.test.php).
  • Whitelisted functions only. system(...), file_get_contents(...) etc. are syntax errors; the whitelist contains only pure string/number/aggregate functions.
  • Auto-escaping applies to results exactly as to plain substitutions; raw remains the single, disable-able hatch.
  • Dynamic keys cannot reach objects (see the containment table above); get() resolves names in the scope stack only.
  • Budgets everywhere: expression length (limits.max_expression_length, default 4 096), nesting depth (32 β€” index chains included), AST memo (4 096-entry FIFO), iterable walks (100 000 items), range size (100 000), key/path lengths (256/128), regex work through Support\Pcre (backtrack-limit hits are no-matches, never hangs or nulls).
  • Arithmetic never fatals: Γ·0 β†’ 0, INF/NAN β†’ 0.
  • Fail-closed kill switch: syntax.expressions: false refuses every %{= …} without evaluating anything (tag kept visible, expr-disabled diagnostic). Pair with allow_raw_filter: false for user-authored templates.
  • Fail-visible errors: a broken expression is kept in the output in lenient mode β€” an attacker (or a bug) cannot use errors to smuggle half-rendered markup; the tag itself is inert text.

Recommended posture by trust level:

Templates come from… Recommended config
Your repository (developers) defaults; strict: true in CI-tested projects
Tenant designers (page builder) expressions: true, allow_raw_filter: false, strict: false
End users / anonymous input expressions: false, allow_raw_filter: false, ssi.exec_mode: deny, ssi.enabled: false

Configuration

Key Default Effect
syntax.expressions true master gate for %{= …} / #{= …}
syntax.strict false broken expressions throw instead of diagnosing
limits.max_expression_length 4096 per-expression source cap

β†’ Configuration Reference

Linting

bin/htmlphp lint (and Engine::lint*()) validate everything above pre-deploy:

Code Severity Meaning
bad-expression error %{= …} / @If / @Set expression failed validation
expressions-disabled error %{= …} while syntax.expressions: false
filter-arg-not-numeric warning e.g. %{done|pct:total} β€” a variable where a literal belongs
unknown-filter error typo in the chain after an expression

β†’ CLI

Pitfalls

  • Filter arguments are literals. %{done|pct:total} divides by the string total β†’ 0. Use %{= done / total * 100}. The linter warns.
  • %{a-b} β‰  %{= a - b}. Bare payloads are names (dashes allowed); the = decides. This is deliberate backward compatibility.
  • Missing names don't trigger missing_var policies inside expressions β€” they are null, like in @If. Use ?? for defaults: %{= price ?? 0 }.
  • get() needs well-formed paths: dotted runs and literal bracket keys. Composing paths at runtime works β€” get('rows.' ~ i ~ '.name'), get('rows[' ~ i ~ ']') (integer keys), and quoted string keys (get("hash['" ~ key ~ "']")). Unquoted non-integer bracket content ('hash[' ~ key ~ ']' with key = 'x-1') is rejected by the grammar β†’ default; add the quotes.
  • Objects are read via dotted paths only β€” obj.field and obj.getField()/obj.isField() work; obj[keyFromData] is always a miss, by design.

Clone this wiki locally