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. Filters
  4. Loops
  5. Conditionals & expressions
  6. Set
  7. Layout inheritance
  8. Partials
  9. Translation blocks
  10. Raw & comments
  11. 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)
#{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 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.

Filters

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

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

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

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