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
- 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) |
#{name} |
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_.-]*)
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.
%{v|filter:arg:'quoted:arg'} β args split on :; quotes protect separators
(|date:'H:i'); \ escapes inside quotes. Filters take & return mixed.
All 46 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
|
| 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).
<!--@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 |
| Compare |
== != === !== < <= > >= (numeric-aware) |
| Logic |
&&/and Β· ||/or Β· !/not
|
| Math |
+ - * / % (Γ·0 β 0) Β· ~ concat |
| Choose |
?: ternary Β· ?? coalesce |
| Sets |
x in list Β· s matches '/re/' (literal patterns only) |
| Functions | count length empty defined min max abs round floor ceil int float str lower upper number default |
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'-->
%{total|number:2}Writes into the current scope frame β inside a loop it dies with the iteration; the outer scope keeps its value.
<!-- 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 |
| 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 β