coolms/dtmpl
A sandboxed template language for non-PHP authors, with lexer, parser, AST and runtime, plus extensible widgets, filters and loaders. No PHP, filesystem or arbitrary calls—framework-optional, with a Symfony bundle available.
1. Verify the PHP constraint before anything else. The package requires PHP ^8.5. Run composer why-not coolms/dtmpl (or just try composer require coolms/dtmpl) — on a typical Laravel app pinned to 8.2–8.4 this will refuse to install. If you cannot move the runtime, stop here; there is no supported fallback.
php -v # must be >= 8.5
composer require coolms/dtmpl
2. Read in this order:
| Where | Why |
|---|---|
README.md |
The language in 30 lines: {if:}, {loop:}, {var: … filter:arg}, {endif}, {endloop} |
docs/ |
Syntax reference — block names, filter call syntax, whitespace handling, escaping rules |
CHANGELOG.md |
Confirms how young the API is; expect churn |
src/ — DtmplEngine, the Loader namespace, Widget and filter contracts |
The four seams you will actually wire up: widgets, filters, constants, loaders |
3. First use case — a designer-editable "content page". Do not start by migrating Blade views. Start with the one thing DTMPL is genuinely better at: a template an editor can change without a deploy.
use CoolMS\Dtmpl\DtmplEngine;
// The engine is framework-optional: a loader + optional cache pool.
// Exact constructor signature lives in src/ and docs/ — check there, not in blog posts.
$engine = new DtmplEngine(/* loader, cache pool */);
$html = $engine->render('page.dtmpl', [
'page' => ['title' => 'Hello', 'isPublished' => true],
'posts' => $postDtos,
]);
Bind it singleton in a service provider, point the loader at resource_path('dtmpl') (or a storage disk for CMS-authored templates), and return response($html) from the controller. That is the whole first increment — no widgets, no custom filters, no cache.
Container binding + config file. Register a config/dtmpl.php with paths, cache_store, widgets, filters, constants, and a DtmplEngine singleton built from it. Everything the host supplies is a constructor/mutator argument, so the provider is the single wiring point; nothing else in the app should new DtmplEngine.
Context as arrays/DTOs, never raw Eloquent models. The engine resolves page.title through symfony/property-access. Models mostly work via ArrayAccess and magic attributes, but you inherit lazy loading inside {loop:} and hidden-attribute surprises. Build an explicit array or readonly DTO in a small "view model" layer: it documents exactly which fields template authors may use, and it makes the sandbox boundary real.
The widget seam → your Blade/components registry. This is the seam worth investing in. Map widget names to small classes with a render(array $props): string shape, and implement them by delegating to Laravel:
// config/dtmpl.php
'widgets' => [
'button' => App\Dtmpl\Widgets\ButtonWidget::class, // returns view('components.button', $props)->render()
'card' => App\Dtmpl\Widgets\CardWidget::class,
],
Template authors then write {widget:button label="Save" variant="primary"} and get your design system with zero PHP exposure. Keep widget props scalar-only and validate them; never pass a prop straight into {!! !!} equivalent output.
Filters as invokable classes. Add project-specific filters (currency, markdown, initials) as single-purpose classes in App\Dtmpl\Filters and register them once. Resist the urge to reach into the engine's internals — filters are the intended extension point for formatting, widgets for markup.
Loader strategy split. Filesystem loader for developer-owned templates in resources/dtmpl/; a database/disk loader for tenant- or CMS-authored templates keyed by tenant:template. Prefer one loader per origin over one loader with magic paths — it keeps cache keys and permission checks honest.
Cache the parsed AST. Lexing and parsing per request is the obvious cost. Feed the engine a PSR-6 pool and key compiled templates on sha1($source) rather than path+mtime so DB-sourced templates invalidate correctly. Ship an Artisan command (dtmpl:clear) — php artisan view:clear will not touch this cache.
Preview and validate before save. Expose a "preview" endpoint that parses and renders against sample context, returning errors with line numbers. This is the killer feature for the designer-facing pitch: authors get feedback in the CMS instead of a 500 on the live site.
Test with golden files. Render each real template against a fixture context and assert against a checked-in HTML snapshot. Because the language is sandboxed and deterministic, snapshot tests catch both engine upgrades and accidental template edits.
Blade interop: if you only need a fragment, a Blade::directive('dtmpl', ...) that renders a DTMPL file to a string and injects it is a low-risk way to adopt the engine incrementally inside existing Blade layouts.
php:8.5-cli), and hosting before you plan anything around this package.psr/cache (PSR-6 CacheItemPoolInterface). Laravel's Cache repository implements PSR-16, not PSR-6. Wrap a Laravel store with symfony/cache's PSR-6 adapter (or cache/adapters); do not expect Cache::store() to be injectable as-is.trans($id, array $parameters) with %placeholder% syntax; Laravel uses __('key', ['name' => …]) with :placeholder. Write a ~30-line adapter class implementing the contract over app('translator') and register it — otherwise translation filters silently mis-substitute or throw.docs/ whether {var:…} auto-escapes or whether you must apply an escape/raw filter. If unescaped output is available and templates can be edited by non-developers, that is a stored-XSS path. Default to escaping and make raw output an explicit, reviewed filter.{loop:posts:post} over a 10k-row collection, or deeply nested loops, will still melt a request. Cap loop sizes in the loader/context, page your collections, and put a time/memory budget around render calls.{loop:posts:post}{var:post.author.name}{endloop} will lazy-load per iteration if you pass models. Eager-load in the context builder or pass DTOs — the template has no way to fix this for you.dtmpl:lint command that walks every registered template and parses it is worth an hour on day one and pays for itself in CI.view:clear, no opcache invalidation, no automatic mtime checks unless you build them. Version cache keys and add a clear command to your deploy script.{php:} block" — the moment PHP leaks in, you have lost the sandbox guarantee and the reason to use this over Blade.DtmplEngine behind your own TemplateRenderer interface so swapping to Twig/Liquid/Blade later is a provider change rather than a codebase-wide edit. Pin the exact version in composer.json and read CHANGELOG.md on every bump.How can I help you explore Laravel packages today?