DTMPL is a template language for people who are not PHP developers. It renders the public site server-side, and it is deliberately not a programming language: a template reads what the context gives it and calls nothing else. No PHP, no filesystem, no arbitrary calls.
The engine ships on its own — coolms/dtmpl, MIT, PHP 8.5. It depends on psr/cache,
symfony/property-access and symfony/translation-contracts, so it runs with no HTTP kernel and
no container. Inside CoolMS it is wired up by coolms/dtmpl-bundle, which registers the engine,
the loader chain, the widget registry and the constant providers.
Why a language of its own#
The first question a Symfony developer asks is why this isn't a general-purpose engine.
There is no second templating layer to unify with. The admin is an Angular application rendered in the browser and fed by the API; the public site is rendered from DTMPL. Nothing in the platform assembles HTML in PHP.
And the one place that does render templates is the place where the author is not the person who deploys the application. On a multi-site install an agency's client edits their own theme, and a template language with function calls becomes a remote-code-execution surface with an editor UI attached. DTMPL removes the surface rather than guarding it — there is nothing executable in the language, and a template can reach only what the context was given.
One shape for everything#
Every tag is {keyword:argument}. Blocks close with {endkeyword}. There is no {/keyword}
form, and no second delimiter for statements versus output — one shape, learned once.
{if:page.isPublished}
<h1>{var:page.title uppercase}</h1>
{loop:posts:post}
<article>{var:post.excerpt truncate:120}</article>
{endloop}
{endif}
String literals are written in backticks, because a template lives inside HTML where " and
' are already spoken for:
{if:post.status=`published`}
{include:`partials/header`}
{var:tags join:`, `}
No whitespace is allowed between the keyword, the colon and the first argument — {var :name}
and {var: name} are both errors. After that, spaces are free.
= means what its position says#
= binds a value in {def:} and in parameters, and compares in {if:}. There is no == — it
is a syntax error, not an alias — and there is no position where = inside {if:} performs an
assignment. One glyph, decided by where it sits.
{ is only special before a keyword#
Tag mode starts only when the letters after { form a registered keyword — a closed set the
lexer and the parser share.
Two constructs sit outside that set on purpose: {verbatim} and {comment}. The lexer resolves
both before the keyword check and neither ever becomes a token the parser sees. That is not an
exception bolted onto the rule, it is the whole mechanism — it is why a verbatim body is emitted
without being read as syntax, and why a comment body reaches no output mode at all rather than
being filtered out later by something that could be configured differently.
Everything else stays literal, so a template can carry PHP, JSON, CSS or set notation with no escaping at all:
class Foo { public function bar() { return 42; } }
{"key": "value", "n": 42}
A = {1, 2, 3} ∪ {4, 5}
A misspelled keyword doesn't fall through silently — it raises a targeted error with a
suggestion: {vra:name} → Unknown DTMPL keyword vra. Did you mean var?
The vocabulary#
| variable | {var:page.title} |
| with filters | {var:page.title uppercase truncate:80} |
| assignment | {def:total=cart.count} |
| conditional | {if:page.isPublished} … {else} … {endif} |
| negated | {unless:user.isAnonymous} … {endunless} — takes no {else} |
| comparison | {if:post.status=`published`}, also != < <= > >= |
| loop | {loop:posts} … {endloop}, or {loop:posts:post} for a named variable |
| include | {include:`partials/header`} — {endinclude} only when it carries fills |
| slot / fill | {slot:main} … {endslot} / {fill:main} … {endfill} |
| constant | {const:SITE_NAME} |
| translation | {t:`Hello`} or {t:`Hello`:`mail`} (key, domain) |
| widget | {widget:comments} or {widget:document:my-slug} |
| verbatim | {verbatim} … {endverbatim} — the lexer emits the body untouched |
| comment | {comment} … {endcomment}, or {comment:note} — removed by the lexer |
Paths walk objects and arrays alike, so {var:page.author.name} doesn't care which is which.
With no operator, {if:} evaluates the value for truthiness.
Loops#
The current element is item unless you name it — name it whenever loops nest. Three modifiers
exist: odd, even, and ``split=`, ``` for a separator between iterations. Eight iteration
variables are available:
loop.index |
0-based position |
loop.index1 |
1-based position |
loop.key |
key of the current element, in both array shapes |
loop.first / loop.last |
edges |
loop.length |
element count |
loop.odd / loop.even |
position parity |
Over a sequential array, item is the element. Over an associative one — or any array
whose keys aren't 0..n-1 — each element is wrapped, so you read item.key and item.value:
{loop:attrs}{var:item.key}="{var:item.value}" {endloop}
A bare {var:item} on a wrapped element renders the JSON of that pair, which is the usual
symptom of forgetting the array is associative.
Filters#
51 built-ins, applied left to right along a path. One colon opens the argument list; further arguments are separated by commas:
{var:title uppercase}
{var:body truncate:120}
{var:body truncate:5,`~~`}
{var:n pad:8,`0`,`left`}
{var:price currency:`EUR`,`de_DE`}
Pipes are decoration — {var:b|uppercase} and {var:b uppercase} are the same thing, the parser
skips the character. Named arguments are not supported.
One distinction is worth learning on day one, because the two look interchangeable and are not:
{var:count default:`none`} count = 0 → "none"
{var:count coalesce:`none`} count = 0 → "0"
default falls back on anything falsy — 0, "0", "", false, an empty array. coalesce
falls back only on null, "" and false, so a real zero survives. Reach for coalesce on
anything numeric.
A fixed allow-list of 41 pure value-transform PHP functions is reachable behind a php. prefix —
{var:name php.strtoupper}. The prefix exists so the boundary is visible in the template. The
list is compiled in; it cannot be extended or narrowed by configuration, and anything off it
raises a SecurityException rather than being called.
Custom filters register on the engine and are ordinary callables taking the value first. Keep them pure: a filter runs once per value per render, so one that touches the database turns a page into N queries.
$engine->registerFilter('initials', $callable);
Composition#
{include:} pulls in another template. {slot:} declares an extension point in a layout and
{fill:} supplies it from the page — an unfilled slot renders its own body as the default:
<!-- layout -->
{slot:main}Nothing here yet.{endslot}
<!-- page -->
{include:`layouts/site`}
{fill:main}<article>{var:page.body}</article>{endfill}
{endinclude}
An include that carries no fills needs no terminator — {include:`partials/header`} on its own
is complete. Write {endinclude} only when there is something between it and the include.
What a name resolves to depends on the loader chain. Loaders are asked in priority order and
the first one that supports a path wins; the filesystem loader sits at priority 0, so anything
meant to override files on disk takes a positive number. An include resolves relative to the
directory of the template doing the including, which is why loaders receive a base path.
This is the seam that lets CoolMS serve templates out of the VFS while the same engine, unchanged, serves them off a disk in a standalone project.
Widgets#
Everything the language can't express is a widget — a component the host registers, named by the author, resolved and called by the engine:
{widget:comments}
{widget:document:my-template-slug}
{widget:comments id=`42`}
A renderer returns anything Stringable, or null to render nothing at all — the honest answer
when a widget has nothing to show, because null lets the surrounding markup disappear with it
while an empty string leaves an empty wrapper behind.
The interesting return type is WidgetResult: Stringable, ArrayAccess and IteratorAggregate
at once, so one return value serves three uses:
{widget:comments} ← render the widget's partial
{var:comments.total} ← read a field off the result
{loop:comments}…{endloop} ← iterate it
Returning a plain string means the widget owns its own HTML, which puts markup inside PHP and out of the designer's reach. Returning a result keeps presentation in the theme.
Which partial renders#
A module contributes a partial name, never a file. The name comes from
dtmpl.widget_templates when the host configured one, otherwise from the renderer's own constant.
The file behind that name is then resolved through the theme chain, first match wins: the active
theme's VFS override root, then the active theme, then each ancestor it extends. The default
partials for the shipped widgets live in theme-default; other themes inherit them rather than
carrying copies.
Two failure modes worth knowing apart. An unregistered widget is silent and renders nothing.
A registered widget whose partial no file provides raises TemplateNotFoundException and the
page render fails.
A widget runs once per occurrence, and a widget inside a {loop:} runs once per iteration. Load
in bulk before the loop.
Output safety#
Output is encoded by default. {var:}, {const:} and {t:} all pass through the encoder on the
way out, so a value carrying markup renders as text rather than as markup.
The opt-out is the raw filter, and it is the thing to audit. {var:page.body raw} turns
encoding off for that value, which is correct where the value really is trusted markup and is a
hole everywhere else. On any template written before 2.0, the migration question is not where do I
add escape — it is which raw did I write, and is that value still trusted.
{verbatim} … {endverbatim} is a different mechanism and not an escaping control: the lexer
emits the block's body untouched without parsing it. {raw} and {endraw} were its 1.x spelling
and now raise a migration error naming the replacement.
The encoder covers the HTML text context and quoted attribute values. It does not make a value safe
in every position a template can put it: a javascript: URL survives encoding untouched in an
href, a value interpolated into a <script> body needs JavaScript string rules rather than HTML
entities, and an unquoted attribute has no delimiters for encoding to protect. See the escaping
section of the language reference for what to reach for in each.
The sandbox is a separate property and holds regardless: a template cannot call arbitrary PHP, cannot read the filesystem, and cannot reach anything the context does not expose.
Templates live in the VFS#
A theme is a directory of nodes, so everything in Permissions & the VFS
applies: a template has an owner, a group and a mode, x is what makes it renderable, and a
client's theme on a multi-site install is isolated by ownership rather than by a role check.
Publishing a template is a write to a file — no build step, no front-end deploy between an editor
changing a page and a visitor seeing it.
Things that trip people up#
- There is no
{/keyword}. Blocks close with{endif},{endloop},{endunless}. The slash form is not an alternative spelling; it is text. - Backticks are the only string syntax.
'value'and"value"inside a tag are not strings. - Filter arguments are comma-separated after one colon. A second colon does not error — the
arguments past it are silently dropped, so
truncate:5:`~~`quietly loses its suffix. - An unrecognised loop modifier is ignored, not rejected.
{loop:items reverse}renders as a plain loop with no warning. - No
and,or, parentheses or unary!. Nest{if:}blocks for and, write two blocks for or, use{unless:}for negation, or precompute a flag in the code that supplies the context. Chained comparisons like{if:a<b<c}are not supported either. - Multi-character operators must be adjacent.
{if:a >= b}parses;{if:a > = b}does not. - Negative number literals don't parse.
{def:offset=-5}throws; write{def:offset=`-5`}or pass the value in through the context. defaulton a number is a bug waiting to happen. Zero is falsy. Usecoalesce.<!-- … -->is not a template comment. It is ordinary text and is emitted to the browser like anything else. Use{comment}…{endcomment}, or{comment:note}inline — those are removed by the lexer and reach no output mode. This matters more than it sounds: a comment is usually where an author writes down what they did not want visible, so the HTML kind leaks in exactly the wrong direction.- Comments do not nest, and an inline one cannot contain
}. The first{endcomment}closes the block, and the inline form ends at the first brace, because neither body is ever parsed. - A loader that says
supports(): trueshadows everything behind it, and the symptom is "my template changes aren't showing up" rather than an error.