Alpha — the platform is in development. Docs cover what ships today; anything unbuilt is marked. Concepts
-rw-rw-r-- content:content /docs/concepts/dtmpl.dtmpl ● verified against v2.0

DTMPL templates

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.
  • default on a number is a bug waiting to happen. Zero is falsy. Use coalesce.
  • <!-- … --> 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(): true shadows everything behind it, and the symptom is "my template changes aren't showing up" rather than an error.