Lightweight, framework-agnostic PHP template engine with auto-escaping, template inheritance, includes, filters, and custom tags.
- PHP 8.1+
No framework dependencies. Works with any PHP project.
composer require enlivenapp/visionCreate the engine once, optionally register any custom tags or filters your app needs, then render .tpl files:
use Enlivenapp\Vision\Engine;
$vision = new Engine();
// Optional: register custom tags (callable inside {% %})
$vision->tags()->register('base_url', fn(string $path = '') => '/myapp/' . ltrim($path, '/'));
$vision->tags()->register('current_year', fn() => date('Y'));
// Optional: register custom filters (used with | in templates)
$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'],
]);That's the full lifecycle. The rest of this document covers template syntax and the engine API in detail.
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 — never reach output) |
{{ ... }} is the default output form. Results are passed 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 (e.g. pre-rendered HTML from a trusted source):
{! article_html !}Inside output and {% %} tags, Vision accepts:
- Variables —
user,user.name,items.0.title - String literals —
'hello'or"world"(either quote style;\escapes the next character) - Number literals —
42,3.14 - Keyword literals —
true,false,null
Branch on a truthy/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: ==, !=, >, <, >=, <=, and, or, 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 using 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 |
— | mb_strtoupper (UTF-8 safe). |
lower |
— | 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 default , thousands and . decimal separators. |
excerpt(length) |
length default 150 |
Strips tags, then truncates to length characters, trims trailing punctuation/whitespace, and appends …. Strings already shorter than length are returned with tags stripped. |
strip_tags |
— | PHP's strip_tags. |
nl2br |
— | PHP's nl2br. Use with {! !} so the inserted <br> is not escaped. |
md5 |
— | md5() of the string form of the value. |
count |
— | count() of any array or Countable; 0 otherwise. |
raw |
— | Marker that tells {{ }} to skip escaping. Only meaningful as the final filter in the chain. |
Unknown filter names return the input unchanged — 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 — useful 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 (literal, variable, filter chain). No tags are registered by default. Unregistered tags produce 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 — the parent's scope is not mutated.
Missing includes silently produce no output. When using 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 if not present, so both {% include 'partials/sidebar' %} and {% include 'partials/sidebar.tpl' %} resolve to the same file.
- Auto-escaping — all
{{ }}output is escaped withhtmlspecialchars()usingENT_QUOTES | ENT_SUBSTITUTEand UTF-8 encoding. - Raw output is deliberate — unescaped output requires the distinct
{! !}syntax, so it's never accidental. - No PHP execution — templates cannot evaluate arbitrary PHP. The expression grammar only supports variable lookup, literals, comparisons, boolean logic, and registered filters/tags.
- Path-traversal protection — include and extends template names containing
..or null bytes resolve to empty and are never read from disk. - Comments stripped at lex time —
{# ... #}content is discarded before parsing and never reaches output.
These are the specific behaviors most likely to surprise you. Nothing here is a bug — each is intentional — but they're worth knowing up front.
- Truthiness is custom, not PHP-standard. Inside
{% if %}, the valuesnull,false,'',0,'0', and[]are falsy; everything else is truthy. This matches most template engines. defaultdoes not treat0as empty.{{ count | default('none') }}renders0whencountis zero, not'none'. Onlynull,'', andfalsetrigger the fallback. If you need a broader "emptiness" check, write a custom filter.- 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 means typos can go unnoticed — check your output, or wraprender()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 — thewithclause is dropped and the name is taken literally. Plain includes without awithclause accept quoted or unquoted names.- Only the first
{% extends %}is honored. A template cannot extend multiple parents; any extraextendsstatements are ignored. - The
rawfilter only matters as the last filter on{{ }}.{{ html | raw }}skips escaping.{{ html | raw | upper }}does not — the last filter isupper, notraw. For raw output, prefer the{! !}delimiter; it's clearer. nl2broutput needs{! !}.{{ text | nl2br }}will escape the inserted<br>tags back into<br>. Use{! text | nl2br !}for the intended effect..tplis appended automatically. You don't need to include the extension ininclude/extendsnames unless you want to — both forms work.true,false,nullare literals.{% if active == true %}and{{ value | default(null) }}treat these as PHP literals, not variable names.
See TESTS.md for the full test suite results covering output, escaping, conditionals, loops, filters, tags, includes, inheritance, literals, and security.
MIT