Repository navigation
Template Syntax
HTML-first template language: every structural tag is an HTML comment, so templates remain valid markup and degrade gracefully in editors.
Note
Canonical spec: docs/TEMPLATE-SYNTAX.md.
This page is the working guide β recipes first, edge cases in callouts.
- Processing order
- Substitutions
- Inline expressions & math
- Filters
- Loops
- Conditionals & expressions
- Set
- Layout inheritance
- Partials
- Translation blocks
- Raw & comments
- Strictness
flowchart LR
A[source] --> B[parse β node tree]
B --> C{extends?}
C -- yes --> D[collect sections,\nrender parent chain β€ 8]
C -- no --> E[tree render\nloopsΒ·ifsΒ·partialsΒ·setΒ·trans\n+ %{} substitution]
D --> E
E --> F[SSI pass]
F --> G{SSI emitted\ntemplate syntax?}
G -- yes --> E2[one guarded re-render] --> H
G -- no --> H[htmlphp.render filter β cache β out]
The parse tree is memoized per engine (LRU 512); identical sources parse once.
| Form | Output |
|---|---|
%{name} |
variable, auto-escaped |
%{user.address.city} |
dot path: arrays β ArrayAccess β public props β label()/getLabel()/isLabel()/hasLabel()
|
%{name|upper|truncate:20:β¦} |
filter chain |
%{html|raw} |
unescaped (policy: syntax.allow_raw_filter) |
%{= done / total * 100 | round:1 } |
inline expression (v5) β compute in place |
#{name} Β· #{= expr}
|
identical alternate braces |
@{page.title} |
translation (catalogue β key itself as fallback) |
{name} |
only with syntax.single_braces: true
|
Anything inside braces that is not a valid name ([A-Za-z_][A-Za-z0-9_.-]*)
and carries no = prefix is left untouched β CSS body { color: red } and
JS if (x) { y() } survive.
Escaping rules (auto-escape is ON by default):
- HTML-escaped (
ENT_QUOTES|ENT_HTML5|ENT_SUBSTITUTE,double_encode: false) unless the chain containsrawor ends in an escaping filter (escape/attr/url/json/js/css/nl2br). - Missing names follow
syntax.missing_var:empty(default, + diagnostic) Β·keepΒ·placeholderΒ·throw.nullvalues are not missing β they render""silently.
(new in v5 β deep dive: Expressions & Math)
%{= EXPR |filters} computes in the tag: the payload after = runs
through the same sandboxed expression language as @If/@Set (closed
interpreter β no eval, no dynamic calls, no I/O), and the result passes
the normal filter + auto-escape pipeline.
Progress: %{= done / total * 100 | round:1 }% β Progress: 14.8%
Total: %{= (price * qty) * (1 - discount / 100) | number:2 } β¬
Stock: %{= stock - reserved > 0 ? 'in stock' : 'sold out' }
<!-- nested variables: hash element by key, key from data -->
%{= prices[currency] } β 12.5 (currency = 'EUR')
%{= rows[i].name } β Second (i = 1)
%{= hash['x-1 y'] } β keys a dot path cannot express
<!-- "variable inside a variable": the NAME comes from the data -->
%{= get(fieldName) } β resolves the variable named by fieldName
%{= get('rows[1].name', 'β') }Rules that keep this safe and predictable:
-
The
=prefix is mandatory.%{a-b}stays a lookup of the variable literally nameda-bβ never a subtraction. Existing templates cannot be reinterpreted. - Missing names inside an expression are
null(β0in arithmetic), like in@If/@Set; themissing_varpolicies apply to plain names only. - Broken expression β tag stays visible in the output + one
expr-invaliddiagnostic (strict mode: exception). Nothing half-renders. -
syntax.expressions: falsecloses the gate completely (fail-closed,expr-disableddiagnostic) β recommended for user-editable templates together withallow_raw_filter: false. - Division/modulo by zero β
0;INF/NANβ0; budgets on length, nesting depth, AST memo and iterable walks.
%{v|filter:arg:'quoted:arg'} β args split on :; quotes protect separators
(|date:'H:i'); \ escapes inside quotes. Filters take & return mixed.
All 57 built-in filters (click to expand)
| Group | Filters |
|---|---|
| Escaping |
escape/esc/e[:strategy] Β· attr Β· url Β· json Β· js Β· css Β· raw
|
| Text |
upper Β· lower Β· ucfirst Β· title Β· words (count!) Β· trim[:chars] Β· pad[:len[:str[:left]]] Β· repeat[:n] Β· replace:from:to Β· truncate[:len[:suffix]] Β· excerpt[:len[:suffix]] Β· nl2br Β· striptags Β· slug[:sep]
|
| Numbers |
number[:dec[:point[:thousands]]] (locale-aware default) Β· round[:n] Β· cleanround[:n] Β· int Β· float Β· div:by (0 on zero divisor) Β· abs
|
| Arithmetic (v5) |
add:n Β· sub:n Β· mul:n Β· mod:n Β· pow:n Β· sqrt Β· pct:total[:dec] Β· sum Β· avg Β· clamp:min:max
|
| Date/time |
date[:format] Β· ago (clock-injected: "3 days ago") |
| Arrays/strings |
join[:sep] Β· split:sep Β· length Β· first Β· last Β· reverse Β· sort[:desc] Β· unique Β· slice:offset[:len] Β· keys
|
| Misc |
default:value Β· translate[:β¦] Β· base64 Β· md5 Β· sha1
|
// custom filters are per-engine instances β no statics
$engine->filters()->add('money', fn(mixed $v): string => 'β¬ ' . number_format((float) $v, 2));Unknown filter β filter-unknown diagnostic + pass-through (or exception in
syntax.strict).
Warning
Filter arguments are literals, never variables. %{done|pct:total}
divides by the string total β 0. The linter warns
(filter-arg-not-numeric); the data-driven form is %{= done / total * 100}.
<!--@Loop rows--> row fields shadow outer scope; scalar = 1 row as %{value}
<!--@Loop item in rows--> alias only
<!--@Loop k, v in map--> key + value
<!--@Loop i in 1..10--> inclusive range (either direction)
<!--@Else--> empty/missing iterable branch
<!--@EndLoop rows-->Inside a row: _index (0-based) Β· _position (1-based) Β· _key Β· _first Β·
_last Β· _count Β· _odd Β· _even Β· and the loop map
(%{loop.index}, %{loop.length}, β¦). <!--@Break--> / <!--@Continue-->
work anywhere inside (typically under an @If).
Iterables: arrays Β· Traversable Β· ArrayAccess&Countable Β· objects (public
props) Β· scalars. Guards: limits.max_loop_rows (truncate + diagnose),
limits.max_loop_depth.
<table>
<!--@Loop k, v in config-->
<tr class="<!--@If _odd-->alt<!--@EndIf-->"><th>%{k}</th><td>%{v|escape}</td></tr>
<!--@Else-->
<tr><td colspan="2">@{config.empty}</td></tr>
<!--@EndLoop-->
</table><!--@If total > 100 && currency == "EUR"-->Free shipping
<!--@Elif total > 0-->%{ship|number:2}
<!--@Else-->Checkout empty
<!--@EndIf-->
<!--@Unless user.hidden-->β¦<!--@EndUnless--> <!-- = If !EXPR -->
<!--@If flag-->β¦<!--@EndIf--> <!-- v3-style truthiness still works -->The expression language is a closed interpreter (no eval, ever):
| Category | Available |
|---|---|
| Literals | numbers, 'strings', true/false/null
|
| Names | scope vars, dot paths |
| Index (v5) |
rows[0] Β· prices[currency] Β· hash['x-1 y'] Β· rows[0].name Β· name[-1] (UTF-8 string offsets) |
| Compare |
== != === !== < <= > >= (numeric-aware) |
| Logic |
&&/and Β· ||/or Β· !/not
|
| Math |
+ - * / % (Γ·0 β 0) Β· ~ concat |
| Choose |
?: ternary Β· ?? coalesce |
| Sets |
x in list Β· s matches '/re/' (literal patterns only) Β· a..b ranges (capped) |
| Functions |
count length empty defined min max abs round floor ceil int float str lower upper number default + v5: pow sqrt sum avg clamp get
|
Truthiness = PHP ('', '0', [], null, false are false). Broken
expression β template-lint diagnostic + false branch (lenient) or
TemplateSyntaxException (strict). ASTs are memoized β 10 000-row loops parse
the condition once.
<!--@Set total="price * qty + shipping"-->
<!--@Set label = 'Ready to ship'-->
<!--@Set share="clamp(done / total * 100, 0, 100)"-->
%{total|number:2} Β· %{share|round:1}%Writes into the current scope frame β inside a loop it dies with the
iteration; the outer scope keeps its value. The value is the full expression
language, so v5 indexing/get()/math work here too. One-off computations
usually read better inline: %{= done / total * 100 | round:1}.
<!-- layouts/base.html -->
<!doctype html>
<title><!--@Section title-->My Site<!--@EndSection--></title>
<main><!--@Section content-->Nothing here.<!--@EndSection--></main><!-- pages/checkout.html -->
<!--@Extends "layouts/base.html"-->
<!--@Section title-->%{shop} β Checkout<!--@EndSection-->
<!--@Section content-->
<h1>@{checkout.heading}</h1>
<!--@SectionParent--> <!-- optional: keep parent default too -->
<!--@EndSection-->- First
@Extendswins; other top-level child output is dropped (Twig rule). - Chains resolve deepest-override-first;
@SectionParentsteps one level down. - Depth > 8 β
TemplateException(cycle guard).
<!--@Include "parts/card.html"--> current scope inherited
<!--@Include "parts/card.html" with="title,url"--> only listed vars (isolated)
<!--@Include "parts/card.html" only--> empty scope
<!--@Include "parts/optional.html" ignore-missing--> silent when absentResolution: including document's directory β templates.paths β
@namespace/ roots; default extension appended. Absolute paths, traversal
and denied extensions are refused (Security).
Missing without ignore-missing β TemplateNotFoundException.
<!--@Trans-->Hello<!--@EndTrans--> static msgid "Hello"
<!--@Trans-->Hello %{name}<!--@EndTrans--> msgid "Hello {0}" + arg
<!--@Trans count="%{n}" domain="shop" context="cart"-->
%{n} item
<!--@Plural-->
%{n} items
<!--@EndTrans-->%{β¦} placeholders become positional {0}, {1}β¦ β exactly what
i18n:extract writes to the POT, so catalogues and templates agree by
construction. {count} is available when count= is set. Args are escaped
per the render's policy. β I18n Guide
<!--@Raw--> %{not_a_var} <!--@If ignored--> <!--@EndRaw-->
<!--@Comment--> design note, never rendered <!--@EndComment-->| Situation | Lenient (default) | syntax.strict: true |
|---|---|---|
| Unbalanced/unknown tags |
always TemplateSyntaxException
|
same |
| Unknown filter | diagnostic + pass-through | exception |
| Broken condition expression | diagnostic + false | exception |
Broken inline %{= β¦}
|
expr-invalid + tag kept visible |
exception |
%{= β¦} with syntax.expressions: false
|
expr-disabled + tag kept |
tag kept (never throws) |
| Missing variable | per missing_var policy |
typically throw
|
Tip
Don't discover any of this at runtime: bin/htmlphp lint templates/
catches all of it pre-deploy with file/line and CI exit codes. β CLI
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 β