coolms / dtmpl
DTMPL template engine: a designer-facing template language with a lexer, parser, AST and runtime, plus a widget seam for host-supplied components
Requires
- php: ^8.5
- psr/cache: ^2.0 || ^3.0
- symfony/property-access: ^8.0
- symfony/translation-contracts: ^3.0
Requires (Dev)
- phpunit/phpunit: ^12.5
This package is auto-updated.
Last update: 2026-08-13 22:34:57 UTC
README
A template language for people who are not PHP developers. Lexer, parser, AST and runtime, with a widget seam so an application can offer components to template authors without exposing any PHP.
{if:page.isPublished}
<h1>{var:page.title uppercase}</h1>
{loop:posts:post}
<article>{var:post.excerpt truncate:120}</article>
{endloop}
{endif}
- Sandboxed by design -- a template reads what the context gives it and calls nothing else. No PHP, no filesystem, no arbitrary calls.
- Framework-optional -- the engine needs no HTTP kernel and no DI container.
For Symfony, add
coolms/dtmpl-bundle. - Extensible where it matters -- widgets, filters, constants and loaders are seams the host fills.
Installation
composer require coolms/dtmpl
Requires PHP ^8.5. Depends on psr/cache and two Symfony contract packages
(property-access, translation-contracts) -- no framework.
use CoolMS\Dtmpl\DtmplEngine; use CoolMS\Dtmpl\Loader\FilesystemTemplateLoader; $engine = new DtmplEngine( loader: new FilesystemTemplateLoader('/path/to/templates'), ); echo $engine->render('page.html.dtmpl', ['page' => $page, 'posts' => $posts]);
Symfony
composer require coolms/dtmpl-bundle
That registers the engine, the loader chain, the widget registry and the constant providers.
Syntax at a glance
Every tag is {keyword:argument}. Blocks close with {endkeyword} -- there is
no {/keyword} form.
| 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} |
| comparison | {if:post.status=published}, also != < <= > >= |
| loop | {loop:posts} ... {endloop} or {loop:posts:post} |
| include | {include:`partials/header`} ... {endinclude} |
| 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} |
| raw block | {raw} ... {endraw} |
String literals use backticks, so a template never fights HTML quoting.
Full reference: docs/language.md.
Filters
51 built-in filters, applied space-separated and taking arguments after a colon:
{var:price currency:`EUR`}
{var:body truncate_words:40}
{var:tags join:`, `}
One distinction is worth learning early, because the two look interchangeable and are not:
{var:count default:`none`} <- 0 is falsy, so this prints "none"
{var:count coalesce:`none`} <- 0 survives; only null/""/false fall back
Full list: docs/filters.md.
Safety
Output is escaped by default. {raw} and the raw filter are the explicit
opt-outs, and the only way to emit unescaped HTML. A template cannot reach
anything the context does not expose, and in strict mode an unknown variable is
an error rather than a silent empty string.
License
MIT © Dmitry Popov