Repository navigation
Expressions And 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.
- Computing a percentage (the 30-second version)
- Inline expressions
%{= β¦} - Nested variables: indexing
- Nested variables:
get()indirection - Math reference
- Security model
- Configuration
- Linting
- Pitfalls
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.
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. Noeval(), no dynamic function calls, no property writes, no file/network access. -
Results flow through the normal pipeline: filter chain, then
auto-escaping.
%{= msg }withmsg = '<b>'renders<b>. -
Filter chains split at the first top-level
|, quote- and bracket-aware:%{= "a|b" | upper}βA|B. -
Missing names are
null(coerced to0in arithmetic,''on output) β consistent with@If/@Set. Themissing_varpolicies apply to plain-name substitutions only. -
Errors stay visible: a malformed expression keeps the tag in the
output (
[%{= 1 + }]) and records oneexpr-invaliddiagnostic; withsyntax.strict: trueit 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.
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.
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.
| 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]
|
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 |
| 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.
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;
rawremains 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 throughSupport\Pcre(backtrack-limit hits are no-matches, never hangs ornulls). -
Arithmetic never fatals: Γ·0 β
0,INF/NANβ0. -
Fail-closed kill switch:
syntax.expressions: falserefuses every%{= β¦}without evaluating anything (tag kept visible,expr-disableddiagnostic). Pair withallow_raw_filter: falsefor 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
|
| 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 |
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
-
Filter arguments are literals.
%{done|pct:total}divides by the stringtotalβ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_varpolicies inside expressions β they arenull, 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 ~ ']'withkey = 'x-1') is rejected by the grammar β default; add the quotes. -
Objects are read via dotted paths only β
obj.fieldandobj.getField()/obj.isField()work;obj[keyFromData]is always a miss, by design.
HTML.PHP 5.0.0 Β· MIT Β© Mikhail Deynekin Β· Repository Β· Releases Β· Report a security issue
Wiki pages mirror docs/ β found a mistake? Edit the page or open an issue.
π Home Problem Domain Getting Started
Template Syntax Expressions & Math SSI Guide I18n Guide WordPress Integration CLI
Configuration API Cheatsheet Diagnostics & Troubleshooting
Architecture Security Model Testing & CI
πΊοΈ ROADMAP Migration 4.x β 5.0 Migration 3.x β 4.0 Contributing Repository β