A lightweight, framework-agnostic PHP twig-like syntax template engine with auto-escaping, template inheritance, includes, filters, and custom tags.
- Output escaped by default, so untrusted values can't slip into your HTML
- Templates never execute PHP; only variables, literals, comparisons, filters, and registered tags run
- Inheritance and includes keep your layouts and partials DRY
- Built-in filters plus custom filters and tags for your own helpers
- No runtime dependencies; works with any PHP project
- PHP 8.1+
There are no framework dependencies.
composer require enlivenapp/visionCreate the engine once, register any custom tags or filters your app needs, then render .tpl files:
use Enlivenapp\Vision\Engine;
$vision = new Engine();
// Custom tags run inside {% %}
$vision->tags()->register('base_url', fn(string $path = '') => '/myapp/' . ltrim($path, '/'));
$vision->tags()->register('current_year', fn() => date('Y'));
// Custom filters are used with |
$vision->filters()->register('slug', fn($val) => strtolower(preg_replace('/[^a-z0-9]+/i', '-', $val)));
echo $vision->render('/path/to/views/page.tpl', [
'title' => 'My Page',
'items' => ['one', 'two', 'three'],
'user' => ['name' => 'Admin', 'email' => 'admin@example.com'],
]);The rest of this document covers the template syntax and the engine API.
Vision templates are plain .tpl files. Four delimited constructs are recognized. Everything outside them is emitted verbatim.
| Delimiter | Purpose |
|---|---|
{{ ... }} |
Output an expression, auto-escaped |
{! ... !} |
Output an expression, raw (no escaping) |
{% ... %} |
Control flow, blocks, includes, extends, and custom tags |
{# ... #} |
Comments (stripped during lexing, so they never reach output) |
{{ ... }} is the default output form. Results pass through htmlspecialchars() with ENT_QUOTES | ENT_SUBSTITUTE and UTF-8, so output is safe by default:
{{ variable }} Auto-escaped
{{ user.name }} Dot-notation into arrays or objects
{{ user.profile.bio }} Nested access at any depthIf any step in a dot-notation chain is missing, the whole expression resolves to null silently.
Use {! ... !} when you intentionally want unescaped output, for example pre-rendered HTML from a trusted source:
{! article_html !}Inside output and {% %} tags, Vision accepts variables like user, user.name, or items.0.title. You can also write string literals in either quote style ('hello' or "world", with \ escaping the next character), number literals like 42 or 3.14, and the keyword literals true, false, and null.
Branch on a truthy or falsy expression. elseif and else are optional.
{% if show_banner %}
<div>Banner</div>
{% endif %}
{% if user %}
<p>Hello {{ user.name }}</p>
{% else %}
<p>Please log in</p>
{% endif %}
{% if role == 'admin' %}
<p>Admin panel</p>
{% elseif role == 'editor' %}
<p>Editor tools</p>
{% else %}
<p>Read only</p>
{% endif %}Supported operators are ==, !=, >, <, >=, <=, and, or, and not.
Vision uses its own truthiness rules. See Notes & Gotchas.
Iterate over any iterable value. Arrays, Traversable objects, and generators all work:
{% for item in items %}
<li>{{ item }}</li>
{% endfor %}
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.excerpt }}</p>
{% endfor %}If the value is not iterable, the loop body is skipped and emits nothing.
Filters transform a value with the | pipe syntax, and they chain left to right:
{{ title | upper }}
{{ name | default('Anonymous') }}
{{ date | date('F j, Y') }}
{{ amount | number_format(2) }}
{{ description | excerpt(100) }}
{{ html_content | strip_tags }}
{{ bio | strip_tags | lower }}| Filter | Arguments | Behavior |
|---|---|---|
default(fallback) |
fallback, default '' |
Returns fallback when the value is null, '', or false. 0, '0', and [] are not treated as empty. |
upper |
none | mb_strtoupper (UTF-8 safe). |
lower |
none | mb_strtolower (UTF-8 safe). |
date(format) |
format, default 'Y-m-d' |
Formats a numeric timestamp or any strtotime-parseable string. Returns the input unchanged if parsing fails. |
number_format(decimals) |
decimals, default 0 |
PHP's number_format with the default , thousands and . decimal separators. |
excerpt(length) |
length, default 150 |
Strips tags, truncates to length characters, trims trailing punctuation and whitespace, and appends …. Strings already shorter than length are returned with tags stripped. |
strip_tags |
none | PHP's strip_tags. |
nl2br |
none | PHP's nl2br. Use it with {! !} so the inserted <br> is not escaped. |
md5 |
none | md5() of the string form of the value. |
count |
none | count() of any array or Countable; 0 otherwise. |
raw |
none | A marker that tells {{ }} to skip escaping. Only meaningful as the final filter in the chain. |
Unknown filter names return the input unchanged, with no error.
$vision->filters()->register('reverse', fn($val) => strrev((string) $val));
$vision->filters()->register('truncate', fn($val, $len = 100) => mb_substr($val, 0, $len) . '...');Custom filters receive the piped value as their first argument. Any arguments in parentheses follow.
Custom tags are plain function calls inside a {% %} block. They work well for URL helpers, site-wide values, translation lookups, and similar:
<link href="{% base_url 'css/style.css' %}" rel="stylesheet">
<footer>© {% current_year %}</footer>Arguments are whitespace-separated and may be any expression (a literal, variable, or filter chain). No tags are registered by default, and an unregistered tag produces no output: no warning, no exception, no placeholder.
Define a parent layout with one or more {% block %}...{% endblock %} regions:
layout.tpl
<!DOCTYPE html>
<html>
<head><title>{{ title }}</title></head>
<body>
<header>Site Header</header>
{% block content %}Default content{% endblock %}
<footer>Site Footer</footer>
</body>
</html>A child template declares {% extends %} and overrides whichever blocks it cares about:
page.tpl
{% extends 'layout' %}
{% block content %}
<h1>{{ title }}</h1>
<p>{{ body }}</p>
{% endblock %}Blocks the child does not override fall through to the parent's default content.
Pull one template into another:
{% include 'partials/sidebar' %}
{% include 'partials/post-card' with {post: post, featured: true} %}Included templates inherit the parent's full variable scope. The optional with { key: value, ... } clause adds or overrides variables for the included template only; it does not mutate the parent's scope.
Missing includes produce no output. When you use the with clause, the template name must be a quoted string (see Notes & Gotchas).
Takes no arguments. Create one instance per application and reuse it.
| Parameter | Purpose |
|---|---|
$templatePath |
Absolute path to the .tpl file to render. |
$data |
Variable context exposed to the template. |
$basePath |
Optional base directory for resolving {% include %} and {% extends %} names. Defaults to dirname($templatePath) . '/'. |
Returns the rendered string, or '' if $templatePath does not exist.
Set $basePath explicitly when your layouts or partials live in a different directory from the template being rendered, for example a shared views/layouts/ tree referenced from module-local templates:
$vision->render(
'/app/modules/blog/views/post.tpl',
$data,
'/app/shared/views/' // includes/extends resolve from here
);Returns the filter registry. Call ->register(string $name, callable $callback) to add custom filters.
Returns the tag registry. Call ->register(string $name, callable $callback) to add custom tags.
Templates use the .tpl extension by convention. Include and extends names have .tpl appended automatically when it is absent, so both {% include 'partials/sidebar' %} and {% include 'partials/sidebar.tpl' %} resolve to the same file.
All {{ }} output is escaped with htmlspecialchars() using ENT_QUOTES | ENT_SUBSTITUTE and UTF-8. Unescaped output needs the distinct {! !} syntax, so raw output is never accidental.
Templates cannot evaluate arbitrary PHP. The expression grammar only supports variable lookup, literals, comparisons, boolean logic, and registered filters or tags.
Include and extends template names that contain .. or null bytes resolve to empty and are never read from disk, which blocks path traversal.
{# ... #} comments are discarded during lexing and never reach output.
These behaviors are intentional, but they tend to surprise people.
Truthiness inside {% if %} is custom, not PHP-standard. null, false, '', 0, '0', and [] are falsy; everything else is truthy. This matches most template engines.
default does not treat 0 as empty. {{ count | default('none') }} renders 0 when count is zero, not 'none'. Only null, '', and false trigger the fallback. Write a custom filter if you need a broader emptiness check.
Missing things fail silently. Missing variables resolve to null. Missing includes, missing extends parents, missing top-level templates, unregistered tags, and unknown filters all produce empty output with no warning. This keeps partial renders alive, but it also means typos can go unnoticed, so check your output or wrap render() with your own logging if you need strictness.
{% include ... with { ... } %} requires a quoted template name. {% include 'card' with {x: 1} %} works; {% include card with {x: 1} %} does not. The with clause gets dropped and the name is taken literally. Plain includes without a with clause accept quoted or unquoted names.
Only the first {% extends %} is honored. A template cannot extend multiple parents; any extra extends statements are ignored.
The raw filter only matters as the last filter on {{ }}. {{ html | raw }} skips escaping. {{ html | raw | upper }} does not, because the last filter is upper, not raw. Prefer the {! !} delimiter for raw output; it is clearer.
nl2br output needs {! !}. {{ text | nl2br }} escapes the inserted <br> tags back into <br>. Use {! text | nl2br !} for the intended effect.
.tpl is appended automatically. You do not need to include the extension in include or extends names; both forms work.
true, false, and null are literals. {% if active == true %} and {{ value | default(null) }} treat them as PHP literals, not variable names.
The test suite covers output, escaping, conditionals, loops, filters, tags, includes, inheritance, literals, and security. See TESTS.md for the full results and how to run them.
MIT