Skip to content

Template Syntax

Mikhail Deynekin edited this page Oct 11, 2026 · 2 revisions

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.

Contents

  1. Processing order
  2. Substitutions
  3. Inline expressions & math
  4. Filters
  5. Loops
  6. Conditionals & expressions
  7. Set
  8. Layout inheritance
  9. Partials
  10. Translation blocks
  11. Raw & comments
  12. Strictness

Processing order

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]
Loading

The parse tree is memoized per engine (LRU 512); identical sources parse once.

Substitutions

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 contains raw or ends in an escaping filter (escape/attr/url/json/js/css/nl2br).
  • Missing names follow syntax.missing_var: empty (default, + diagnostic) Β· keep Β· placeholder Β· throw. null values are not missing β€” they render "" silently.

Inline expressions & math

(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 named a-b β€” never a subtraction. Existing templates cannot be reinterpreted.
  • Missing names inside an expression are null (β†’ 0 in arithmetic), like in @If/@Set; the missing_var policies apply to plain names only.
  • Broken expression β†’ tag stays visible in the output + one expr-invalid diagnostic (strict mode: exception). Nothing half-renders.
  • syntax.expressions: false closes the gate completely (fail-closed, expr-disabled diagnostic) β€” recommended for user-editable templates together with allow_raw_filter: false.
  • Division/modulo by zero β†’ 0; INF/NAN β†’ 0; budgets on length, nesting depth, AST memo and iterable walks.

Filters

%{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}.

Loops

<!--@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>

Conditionals & expressions

<!--@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

<!--@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}.

Layout inheritance

<!-- 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 @Extends wins; other top-level child output is dropped (Twig rule).
  • Chains resolve deepest-override-first; @SectionParent steps one level down.
  • Depth > 8 β†’ TemplateException (cycle guard).

Partials

<!--@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 absent

Resolution: 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.

Translation blocks

<!--@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 & comments

<!--@Raw--> %{not_a_var} <!--@If ignored--> <!--@EndRaw-->
<!--@Comment--> design note, never rendered <!--@EndComment-->

Strictness

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

Clone this wiki locally