dalpras / smart-template
Smart template engine for key-value substitutions
Requires
- php: >=8.3
Requires (Dev)
- phpunit/phpunit: ^12.4
README
A small PHP template engine for structured rendering with native PHP arrays, lazy compilation, presets, and named placeholder substitution.
smart-template is designed for projects that want to keep rendering logic in PHP without introducing a separate template language.
Templates are regular PHP arrays made of strings, callbacks, and nested arrays. String leaves are lazily compiled into render closures when accessed.
What it is good at
- Small, reusable HTML fragments and components
- PHP-native template composition
- Dynamic rendering with named placeholders
- Preset-based template registration
- In-memory template collections
- Fine-grained control over escaping and HTML attributes
- Exact custom parameter callbacks
- Call-site token modifiers
- Configurable modifier separator
What it is not
This package is not a full view framework.
It does not try to replace systems built around inheritance, blocks, macros, filters, or expression languages.
It works best when you want explicit PHP control over markup and composition.
Installation
composer require dalpras/smart-template
Requirements
- PHP 8.3 or newer
Mental model
A template collection is an array registered under a namespace.
Each entry can be:
- a string with placeholders such as
{title}or{rows} - a closure
- a nested array of more templates
- a lazy template-file reference
When a string leaf is accessed, the engine compiles it lazily into a closure.
Example:
echo $template['card']([ '{title}' => 'Hello', '{body}' => 'Welcome', ]);
If a placeholder value is itself a closure, the engine resolves it before substitution.
Quick start
1) Create a preset
<?php namespace App\Template; use DalPraS\SmartTemplate\PresetInterface; use DalPraS\SmartTemplate\TemplateEngine; final class UiPreset implements PresetInterface { public const NAMESPACE = 'ui'; public static function register( TemplateEngine $engine, string $namespace = self::NAMESPACE, array $overrides = [], bool $default = true, ): TemplateEngine { $engine->register($namespace, [ 'card' => <<<'HTML' <div class="card"> <h2>{title}</h2> <div>{body}</div> </div> HTML, ], default: $default); if ($overrides !== []) { $engine->register($namespace, $overrides); } return $engine; } }
2) Register the preset
use DalPraS\SmartTemplate\TemplateEngine; $engine = new TemplateEngine(); UiPreset::register($engine);
3) Render it
echo $engine->renderDefault(function ($ui) { return $ui['card']([ '{title}' => 'Hello', '{body}' => 'This is rendered with Smart Template.', ]); });
Output:
<div class="card"> <h2>Hello</h2> <div>This is rendered with Smart Template.</div> </div>
Presets
A preset is a class that registers templates into the engine.
Presets replace automatic filesystem loading. The engine does not scan directories or resolve template names from the filesystem.
$engine = new TemplateEngine(); UiPreset::register($engine);
Then render the registered collection:
echo $engine->render('ui', function ($ui) { return $ui['card']([ '{title}' => 'Hello', '{body}' => 'Preset rendering', ]); });
The first registered collection automatically becomes the default collection.
$ui = $engine->collection(); echo $ui['card']([ '{title}' => 'Hello', '{body}' => 'Default collection', ]);
You can also mark a collection as default explicitly:
$engine->register('ui', $templates, default: true);
PresetInterface
Presets can implement PresetInterface.
use DalPraS\SmartTemplate\PresetInterface; use DalPraS\SmartTemplate\TemplateEngine; use DalPraS\SmartTemplate\Collection\RenderCollection; final class HtmlPreset implements PresetInterface { public const NAMESPACE = 'html'; public static function register( TemplateEngine $engine, string $namespace = self::NAMESPACE, array $overrides = [], bool $default = true, ): TemplateEngine { $templates = $engine->require(self::path()); if (!is_array($templates)) { throw new \RuntimeException('HtmlPreset root template must return an array.'); } $engine->register($namespace, $templates, default: $default); if ($overrides !== []) { $engine->register($namespace, $overrides); } return $engine; } public static function collection( TemplateEngine $engine, ?string $namespace = self::NAMESPACE, ): RenderCollection { return $engine->collection($namespace); } public static function path(): string { return dirname(__DIR__, 2) . '/resources/templates/html.php'; } }
Usage:
$engine = new TemplateEngine(); HtmlPreset::register($engine); $html = $engine->collection(); echo $html['div']([ '{attributes}' => 'class="box"', '{body}' => 'Hello', ]);
Register it under a custom namespace:
HtmlPreset::register($engine, namespace: 'html', default: false);
Then render it explicitly:
echo $engine->render('html', function ($html) { return $html['div']([ '{attributes}' => 'class="box"', '{body}' => 'Hello', ]); });
Application UI preset example
An application preset can register the built-in HTML preset and merge application templates into the same namespace.
final class UiPreset implements PresetInterface { public const NAMESPACE = 'ui'; public static function register( TemplateEngine $engine, string $namespace = self::NAMESPACE, array $overrides = [], bool $default = true, ): TemplateEngine { $templates = $engine->require(self::path()); if (!is_array($templates)) { throw new \RuntimeException( 'UiPreset template file must return an array: ' . self::path() ); } $engine->register($namespace, $templates, default: $default); if ($overrides !== []) { $engine->register($namespace, $overrides); } return $engine; } public static function path(): string { return dirname(__DIR__, 2) . '/templates/default.php'; } public static function collection( TemplateEngine $engine, ?string $namespace = self::NAMESPACE, ): RenderCollection { return $engine->collection($namespace); } }
Usage:
$engine = new TemplateEngine(); UiPreset::register($engine); $ui = $engine->collection(); echo $ui['button']([ '{label}' => 'Save', ]);
Loading templates from PHP files
The engine provides require() as a low-level helper for presets.
This is explicit loading, not filesystem discovery.
$templates = $engine->require(__DIR__ . '/templates/default.php'); if (!is_array($templates)) { throw new RuntimeException('Template file must return an array.'); } $engine->register('ui', $templates);
Example template file:
<?php return [ 'card' => <<<'HTML' <div class="card"> <h2>{title}</h2> <div>{body}</div> </div> HTML, 'button' => <<<'HTML' <button>{label}</button> HTML, ];
Registering and extending templates
Use register() to create a namespace or merge templates into an existing namespace.
Create a namespace:
$engine->register('ui', [ 'button' => '<button>{label}</button>', ]);
Merge more templates into the same namespace:
$engine->register('ui', [ 'card' => '<div class="card">{body}</div>', ]);
Override an existing template:
$engine->register('ui', [ 'button' => '<button class="btn">{label}</button>', ]);
Result:
$ui = $engine->collection('ui'); echo $ui['button']([ '{label}' => 'Save', ]); echo $ui['card']([ '{body}' => 'Hello', ]);
The rule is:
register('new_namespace', $templates); // create register('existing_namespace', $templates); // merge or override
addCustom() may remain as a backward-compatible alias, but new code should use register().
Default collection
The first registered namespace becomes the default collection.
$engine->register('ui', [ 'title' => '<h1>{text}</h1>', ]); $ui = $engine->collection(); echo $ui['title']([ '{text}' => 'Hello', ]);
Set the default namespace manually:
$engine->setDefaultNamespace('ui');
Access it explicitly:
$ui = $engine->defaultCollection();
Nested templates
Nested arrays are wrapped into RenderCollection objects.
$engine->register('mail', [ 'layout' => [ 'page' => <<<'HTML' {header} {content} HTML, ], 'partials' => [ 'title' => '<h1>{text}</h1>', ], ]); $mail = $engine->collection('mail'); echo $mail['layout']['page']([ '{header}' => $mail['partials']['title']([ '{text}' => 'Newsletter', ]), '{content}' => '<p>Welcome.</p>', ]);
Lazy template files
A template file can reference another file lazily.
<?php return [ 'layout' => [ 'page' => '{content}', ], 'partials' => $this->lazyRequire(__DIR__ . '/partials.php'), ];
partials.php:
<?php return [ 'title' => '<h1>{text}</h1>', 'paragraph' => '<p>{text}</p>', ];
The lazy file is loaded only when that branch is accessed.
Lazy placeholder closures
A placeholder value can be a closure.
echo $engine->render('mail', function ($mail) { return $mail['layout']['page']([ '{header}' => fn ($root) => $root['partials']['title']([ '{text}' => 'Newsletter', ]), '{content}' => '<p>Welcome.</p>', ]); });
Placeholder closures receive:
- the root
RenderCollection - the current scoped
RenderCollection - the
TemplateEngine - the current namespace
'{content}' => function ($root, $scope, $engine, $namespace) { return $root['partials']['paragraph']([ '{text}' => 'Generated lazily', ]); }
Rendering attributes
The engine includes an attributes() helper.
echo '<input ' . $engine->attributes([ 'id' => 'user[email]', 'name' => 'user[email]', 'title' => 'Email address', 'class' => 'form-control', ]) . '>';
Example output:
<input id="user-email" name="user[email]" title="Email address" class="form-control">
Notes:
idvalues are normalized into an HTML-friendly formid,title,name, andaltcan be escaped through configured helpers- closures are supported as attribute values
echo '<button ' . $engine->attributes([ 'class' => fn () => 'btn btn-primary', 'title' => fn () => 'Save changes', ]) . '>Save</button>';
Custom parameter callbacks
You can register callbacks that transform or provide exact token values before final substitution.
A common use case is rendering HTML attributes from an array.
$engine->addCustomParamCallback( '{attributes}', static function ($value) use ($engine): string { return $value === null ? '' : $engine->attributes((array) $value); } ); $engine->addCustomParamCallback( '{class}', static fn ($value): string => $value === null ? '' : trim((string) $value) );
Then templates can use {attributes} consistently:
$engine->register('ui', [ 'button' => '<button {attributes}>{label}</button>', ]); echo $engine->collection('ui')['button']([ '{attributes}' => [ 'type' => 'button', 'class' => 'btn btn-primary', ], '{label}' => 'Save', ]);
Custom parameter callbacks are matched by exact token name.
Call-site token modifiers
Call-site token modifiers let the caller transform a value while keeping the template unchanged.
The modifier is written in the argument key, not inside the template.
Template:
$engine->register('ui', [ 'title' => '<h2 class="{class}">{content}</h2>', ]);
Register a modifier:
$engine->addCustomParamModifier( 'upperCase', static fn (mixed $value): string => mb_strtoupper((string) $value, 'UTF-8') );
Usage:
echo $engine->collection('ui')['title']([ '{class}' => 'fs-3', '{content}|upperCase' => 'hello world', ]);
Output:
<h2 class="fs-3">HELLO WORLD</h2>
Internally, this:
[
'{content}|upperCase' => 'hello world',
]
is resolved before rendering as:
[
'{content}' => 'HELLO WORLD',
]
The template still contains only:
{content}
Chained modifiers
Modifiers can be chained.
$engine->addCustomParamModifier( 'trim', static fn (mixed $value): string => trim((string) $value) ); $engine->addCustomParamModifier( 'upperCase', static fn (mixed $value): string => mb_strtoupper((string) $value, 'UTF-8') ); echo $engine->collection('ui')['title']([ '{class}' => 'fs-3', '{content}|trim|upperCase' => ' hello world ', ]);
Output:
<h2 class="fs-3">HELLO WORLD</h2>
Modifiers run before parameter callbacks
Modifiers are resolved before custom parameter callbacks.
This is useful when a modifier prepares structured data that a callback will later render.
$engine->addCustomParamModifier( 'primaryButton', static function (mixed $value): array { $attributes = is_array($value) ? $value : []; $attributes['class'] = trim(($attributes['class'] ?? '') . ' btn btn-primary'); return $attributes; } ); $engine->addCustomParamCallback( '{attributes}', static fn (mixed $value): string => $value === null ? '' : $engine->attributes((array) $value) );
Template:
$engine->register('ui', [ 'button' => '<button {attributes}>{content}</button>', ]);
Usage:
echo $engine->collection('ui')['button']([ '{attributes}|primaryButton' => [ 'type' => 'submit', ], '{content}' => 'Save', ]);
Output:
<button type="submit" class="btn btn-primary">Save</button>
Custom token styles
Smart Template does not require {...} tokens.
The token used in the caller only has to match the token used in the template.
For example, this template:
$engine->register('ui', [ 'title' => '<h2>%content%</h2>', ]);
can be rendered with:
echo $engine->collection('ui')['title']([ '%content%|upperCase' => 'hello world', ]);
The engine resolves %content%|upperCase into %content%, then replaces %content% in the template.
These token styles are all valid:
'{content}|upperCase' '%content%|upperCase' '[[content]]|upperCase' ':content:|upperCase' '{{ content }}|upperCase'
The base token must not contain the configured modifier separator.
Configurable modifier separator
The default modifier separator is:
|
So this works by default:
echo $engine->collection('ui')['title']([ '{content}|upperCase' => 'hello world', ]);
You can choose a different separator:
$engine->setCustomParamModifierSeparator('::');
Then use it in call-site arguments:
echo $engine->collection('ui')['title']([ '{content}::upperCase' => 'hello world', ]);
Chaining also uses the configured separator:
echo $engine->collection('ui')['title']([ '{content}::trim::upperCase' => ' hello world ', ]);
Choose a separator that does not appear inside your tokens or modifier names.
Cross-collection composition
Collections can compose other registered collections by using the same engine instance.
$engine->register('icons', [ 'save' => '<span>{content}</span>', ]); $engine->register('ui', [ 'button' => '<button>{icon}{label}</button>', ]); echo $engine->render('ui', function ($ui) use ($engine) { return $ui['button']([ '{icon}' => $engine->collection('icons')['save']([ '{content}' => '...', ]), '{label}' => 'Save', ]); });
Return values and stringification
When placeholders are substituted, the engine converts values to strings.
Common cases are handled:
- strings
- integers and floats
- booleans
nullDateTimeInterface- enums
Stringable- arrays and objects through JSON-style encoding
Example:
$engine->register('demo', [ 'line' => '<p>{value}</p>', ]); $demo = $engine->collection('demo'); echo $demo['line']([ '{value}' => new DateTimeImmutable('2026-03-24 10:00:00'), ]);
For HTML output, escaping is still your application's responsibility.
Recommended usage pattern
A reliable way to use this package is:
- Register templates through presets.
- Use semantic namespaces such as
html,ui, ormail. - Use
register()again to merge into or override an existing namespace. - Keep template strings focused on markup.
- Use named tokens consistently.
- Use call-site token modifiers for caller-side transformations.
- Escape untrusted content at the application boundary.
- Reuse a
TemplateEngineinstance instead of creating one per render.
Error handling
collection() throws when the namespace is not registered.
try { $tpl = $engine->collection('missing'); } catch (\Throwable $e) { // log or recover }
require() throws when the explicit file path does not exist.
try { $templates = $engine->require(__DIR__ . '/missing.php'); } catch (\Throwable $e) { // log or recover }
Template files used by presets should return arrays.
Example project structure
src/
|-- Template/
| `-- UiPreset.php
`-- templates/
|-- default.php
`-- partials.php
src/Template/UiPreset.php:
final class UiPreset implements PresetInterface { public const NAMESPACE = 'ui'; public static function register( TemplateEngine $engine, string $namespace = self::NAMESPACE, array $overrides = [], bool $default = true, ): TemplateEngine { $templatesDir = dirname(__DIR__) . '/templates'; $templates = $engine->require($templatesDir . '/default.php'); return $engine->register($namespace, $templates, default: $default); } }
src/templates/default.php:
<?php return [ 'layout' => [ 'page' => '{content}', ], 'partials' => $this->lazyRequire(__DIR__ . '/partials.php'), ];
src/templates/partials.php:
<?php return [ 'title' => '<h1>{text}</h1>', ];
Security notes
This package gives you control, but it does not automatically make HTML safe.
Be careful with:
- user-controlled HTML
- attribute values
- URLs
- inline JavaScript
- mixed trusted and untrusted content
Treat output escaping as an application concern.
Use your escaper/helpers consistently when rendering untrusted data.
When to choose this package
Choose Smart Template when:
- you want a small PHP-native renderer
- you prefer arrays and closures over a custom template language
- you are rendering many small reusable fragments
- you want explicit control over composition
- you want preset-based template registration
- you want caller-side transformations without template-side expressions
Consider a larger templating system when:
- your team wants strict separation between PHP and views
- you need inheritance, blocks, macros, filters, or expression languages
- you want a larger ecosystem of integrations and tooling
Minimal reference
TemplateEngine
new TemplateEngine()
Main methods:
render(string $namespace, Closure $callback): mixed renderDefault(Closure $callback): mixed collection(?string $namespace = null): RenderCollection defaultCollection(): RenderCollection hasCollection(string $namespace): bool register(string $namespace, array|RenderCollection $templates, bool $default = false): static setDefaultNamespace(string $namespace): static getDefaultNamespace(): ?string require(string $path): mixed lazyRequire(string $path): LazyTemplateFile attributes(array $attributes): string addCustomParamCallback(string $name, Closure $callback): static removeCustomParamCallback(string $name): bool getCustomParamCallbacks(): array addCustomParamModifier(string $name, Closure $callback): static removeCustomParamModifier(string $name): bool getCustomParamModifiers(): array setCustomParamModifierSeparator(string $separator): static getCustomParamModifierSeparator(): string
PresetInterface
public static function register( TemplateEngine $engine, string $namespace = '', array $overrides = [], bool $default = true, ): TemplateEngine;
RenderCollection
Collections behave like nested arrays with lazy wrapping and lazy compilation.
$ui = $engine->collection('ui'); echo $ui['button']([ '{label}' => 'Save', ]);
Migration from filesystem loading
Previous versions allowed filesystem-oriented usage like:
$engine = new TemplateEngine( directory: $templatesDir, default: 'default.php', preload: true, ); $engine->render('default.php', ...);
The new model is preset-based:
$engine = new TemplateEngine(); UiPreset::register($engine); $engine->renderDefault(...);
Or explicitly:
$engine->render('ui', ...);
The engine no longer scans template directories or resolves template files by name.
Files are loaded only when a preset explicitly calls require().
Avoid using filenames as namespaces:
// Avoid public const NAMESPACE = 'default.php'; // Prefer public const NAMESPACE = 'ui';
License
See the package metadata in composer.json / Packagist and keep the README aligned with that metadata.