A fast, expressive PHP template engine. Templates (.blate files) are compiled once to
plain PHP class files and cached on disk - subsequent renders just include the cached
file, so there is no parsing overhead at runtime.
- Installation
- Quick Start
- Cache Configuration
- Printing Values
- Expressions
- Blocks
- Helpers Reference
- Custom Blocks
- Custom Helpers
- Template Scope
- Global Variables
- Project Configuration (
.blate.php) - Disabling Blocks and Helpers
- Editor Support
- Comparison: Blate vs Twig vs Blade
composer require silassare/blateuse Blate\Blate;
// Render a template file
$html = Blate::fromPath('/path/to/views/page.blate')
->render(['title' => 'Home', 'user' => ['name' => 'Alice', 'admin' => true]]);
// Render a template string
$html = Blate::fromString('Hello, {name}!')
->render(['name' => 'World']);A minimal template:
<!DOCTYPE html>
<html>
<head><title>{title}</title></head>
<body>
{@if user.admin}
<p>Welcome back, admin {user.name}!</p>
{:else}
<p>Hello, {user.name}.</p>
{/if}
</body>
</html>
By default compiled templates are cached in blate_cache/ relative to each source file.
You can set a global custom directory:
Blate::setCacheDir('/var/cache/blate');
echo Blate::getCacheDir(); // /var/cache/blateThe cache is automatically invalidated whenever the source content, file path, or
Blate::VERSION changes. No manual cache busting is needed.
| Syntax | Effect |
|---|---|
{name} |
Print escaped (htmlspecialchars, XSS-safe) |
{= name} |
Print raw / unescaped |
{'hello'} |
Print a literal string |
{42} |
Print a literal number |
{foo.bar} |
Print property chain (escaped) |
Standard arithmetic and string operators are supported inside { }:
{a + b}
{price * qty}
{'Hello, ' + name + '!'}
{score - penalty}
{total / count}
{@if age >= 18}adult{/if}
{@if role == 'admin'}...{/if}
{@if x != y}...{/if}
{@if a <= b}...{/if}
{@if isLoggedIn && hasAccess}...{/if}
{@if isGuest || isBot}...{/if}
{@if !isHidden}...{/if}
?? returns the right-hand side when the left-hand value is null:
{user.nickname ?? 'Guest'}
{config.title ?? 'Untitled'}
true, false, and null are PHP literals when used as expression heads.
All casings are supported (TRUE, FALSE, NULL).
Inside a dot-chain they remain normal property lookups.
{@if true}always{/if}
{@if false}never{:else}always{/if}
{@if null}never{:else}always{/if}
{= value ?? null} -- null as null-coalesce fallback
{$if(true, 'yes', 'no')} -- literal as helper argument
{foo.null} -- property lookup, not a literal
String literals in expressions follow the same escape rules as PHP single-quoted and double-quoted strings:
\'— escaped single quote inside a single-quoted string\\— escaped backslash (produces one\character)- A trailing
\before the closing quote (e.g.'boo\') is treated as an escaped quote, so the string never closes and a parse error is thrown — same as PHP. Use\\to end a string with a literal backslash:'boo\\'.
{foo['bar\\baz']} -- key is bar\baz (one backslash)
{foo['it\'s fine']} -- key is it's fine
This escaping applies only to string literals inside expressions ({...}).
Backslashes in raw template text outside tags are passed through unchanged.
use {=namespace}\{=class_name}; -- \ is literal text between two expressions
Apply a helper as a filter with |. The left-hand expression becomes the first
argument. Multiple pipes are chained left to right:
{name | upper} -- upper(name)
{body | truncate(120)} -- truncate(body, 120)
{price | number(2) | escape} -- escape(number(price, 2))
{tags | join(', ')} -- join(tags, ', ')
Pipe-filter names always resolve against the helpers layer only — a callable
stored in user data cannot be used as a pipe filter, even if its key matches.
Use {foo | upper} only for registered helpers; to call a user-data callable
directly, use {upper(foo)} (full-stack lookup) or pass it through a helper.
Use dot notation to traverse nested data. Square-bracket subscripts are supported:
{user.address.city}
{items[0].name}
{items[idx].value}
$$ refers to the raw data passed to render(). Useful when forwarding context:
{@import 'partials/header.blate' $$}
{@extends 'layouts/base.blate' $$}
All block tags follow the pattern {@name ...}...{/name}.
Breakpoints use {:name}.
Assign one or more variables in the current data scope:
{@set count = items|length; label = 'Items'}
{count} {label}
{@if score >= 90}
Excellent
{:elseif score >= 60}
Passing
{:else}
Below average
{/if}
Iterate over a list. Supports key and index variables.
The optional {:else} branch renders when the list is empty or null.
Three built-in variables are injected into every iteration's scope regardless of the syntax form used:
| Variable | Type | Description |
|---|---|---|
is_first |
bool |
true on the first iteration |
is_last |
bool |
true on the last iteration |
At runtime the iterable is normalized to an Iterator: IteratorAggregate
instances are unwrapped, plain arrays are wrapped in ArrayIterator. The loop
then fetches each element via current()/key(), advances with next(), and
reads valid() to determine is_last — without materializing the whole
sequence first. Memory usage is O(1) even for generators or large database
cursors.
{@each item in products}
<li
{@if is_first}class="first"{/if}
{@if is_last}class="last"{/if}
>{item.name} - {item.price | $number(2)}</li>
{:else}
<li>No products found.</li>
{/each}
<!-- with key -->
{@each item:key in map}
{key}: {item}
{/each}
<!-- with key and index -->
{@each item:key:idx in list}
{idx}. [{key}] {item} (first={is_first}, last={is_last})
{/each}
Branch on a single expression using strict equality (===).
{:default} is optional and must come after all {:case} branches.
{@switch status}
{:case 'active'}
<span class="green">Active</span>
{:case 'banned'}
<span class="red">Banned</span>
{:default}
<span>Unknown</span>
{/switch}
Loop a block n times. Optionally expose the 0-based index as a variable.
Two built-in variables are injected on every iteration:
| Variable | Type | Description |
|---|---|---|
is_first |
bool |
true on the first iteration |
is_last |
bool |
true on the last iteration |
{@repeat 3}*{/repeat} -- outputs: ***
{@repeat count as i}
Row {i}
{/repeat}
{@repeat 3}
{@if is_first}<ul>{/if}
<li>item</li>
{@if is_last}</ul>{/if}
{/repeat}
The count expression can be any expression: {@repeat items|length as i}.
Render a block body into a string variable instead of printing it immediately:
{@capture greeting}
Hello, {user.name}! You have {count} messages.
{/capture}
<title>{greeting | stripTags}</title>
<body>{= greeting}</body>
Create an isolated child scope. Variables set inside do not leak to the parent. Parent variables remain readable inside the scope:
{@set x = 10}
{@scoped}
{@set x = 99}
Inside: {x} -- 99
{/scoped}
Outside: {x} -- 10
Define named slots in a base layout and override them in child templates.
layouts/base.blate:
<!DOCTYPE html>
<html>
<head>
<title>{@slot title}My Site{/slot}</title>
</head>
<body>
<main>{@slot body}Default content{/slot}</main>
<footer>{@slot footer}(c) 2025{/slot}</footer>
</body>
</html>
pages/about.blate:
{@extends 'layouts/base.blate' $$}
{@slot title}About Us{/slot}
{@slot body}
<p>We build great software.</p>
{/slot}
{/extends}
The context argument ($$ or any expression) is passed to the base template.
Only {@slot} tags and whitespace are allowed directly inside {@extends}.
Render another template inline with a given data context:
{@import 'partials/nav.blate' $$}
{@import 'partials/card.blate' product}
Include a file's raw content without any template processing:
{@import_raw 'assets/logo.svg'}
{@import_raw 'content/terms.html'}
Output a literal block bypassing all Blate processing:
{@raw}
Example: {@if condition}...{/if}
{/raw}
Stripped at compile time; never appear in rendered output:
{# This is a comment #}
{#
Multi-line comment.
Nothing here reaches the output.
#}
Embed arbitrary PHP expressions directly (use sparingly):
{~ $ts = time(); ~}
Created: {~ echo date('Y-m-d', $ts); ~}
The snippet is emitted verbatim into the compiled template's build() method,
which receives $context as its only parameter. $context is the
Blate\DataContext object for the current render and exposes the full scope
stack. Common uses:
{~ $val = $context->chain()->get('user')->get('name')->val(); ~}
{~ $context->set('computed', strtoupper((string) $val)); ~}
Name: {computed}
All standard PHP globals ($_SERVER, $_SESSION, etc.) and any PHP variables
defined in earlier {~ ~} blocks within the same template are also in scope.
Php snippets inside {@extends} child templates share the same scope as their
parent's build() call.
Helpers are callable functions registered globally with Blate::registerHelper.
They can be called in expressions and as pipe filters.
There are three ways to invoke a helper named upper:
{upper(title)} -- full stack lookup: user-data key 'upper' shadows the helper
{$upper(title)} -- helper-only lookup: immune to user-data shadowing (preferred)
{title | upper} -- pipe filter: always uses helper-only lookup (same as $upper)
The $ prefix and pipe filters both bypass the variable scope stack and consult
only the registered helpers layer. A callable stored in user data can never be
invoked as a pipe filter; only registered helpers are resolved in that position.
Prefer $helper(...) over helper(...) in template expressions. The bare
form resolves through the full scope stack and silently changes behaviour if
render data contains a key with the same name — a hard-to-trace runtime bug.
Use the bare form only when intentional user-data shadowing is desired.
| Helper | Signature | Description |
|---|---|---|
has |
has(target, prop) |
true if target has property/index prop. |
type |
type(value, type?) |
Without type: returns debug type string. With type: returns true/false. Accepted types: null, bool, int, float, string, array, object, numeric, or any FQCN. |
cast |
cast(value, type) |
Cast to int, float, string, bool, or array. |
default |
default(value, fallback) |
Returns value unless null or ''; otherwise fallback. |
if |
if(condition, a, b) |
Returns a when truthy, b otherwise. |
| Helper | Signature | Description |
|---|---|---|
escape |
escape(value) |
htmlspecialchars - applied automatically to {expr} output. |
escapeHtml |
escapeHtml(value) |
htmlspecialchars with ENT_QUOTES|ENT_SUBSTITUTE. Preserves UTF-8 multibyte chars; use for explicit escaping. |
attrs |
attrs(array, raw?) |
Build an HTML attribute string. Default: false/null/'' omit the attribute, true emits a standalone boolean attribute (e.g. disabled). Pass raw=1 (in templates) or true (in PHP) to emit all values as strings instead -- useful for data-* / ARIA attributes where false means "false", not absent. |
quote |
quote(str) |
Wrap in single quotes, escaping internal quotes. |
unquote |
unquote(str) |
Strip surrounding single or double quotes. |
concat |
concat(a, b, ...) |
Concatenate strings (variadic). |
upper |
upper(str) |
Multibyte uppercase. |
lower |
lower(str) |
Multibyte lowercase. |
ucfirst |
ucfirst(str) |
Uppercase first character (multibyte-safe). |
trim |
trim(str, chars?) |
Strip leading/trailing characters (default: whitespace). |
replace |
replace(str, search, replace) |
str_replace wrapper. |
split |
split(str, sep, limit?) |
Split; empty separator splits into characters. |
substr |
substr(str, start, length?) |
Multibyte substring. |
truncate |
truncate(str, length, suffix?) |
Truncate, appending suffix (default '...'). |
nl2br |
nl2br(str) |
Insert <br> before newlines. |
url |
url(str) |
RFC-3986 URL-encode. |
json |
json(value, flags?, pretty?) |
json_encode with JSON_HEX_TAG|JSON_HEX_AMP|JSON_THROW_ON_ERROR by default. <, >, & are unicode-escaped so the output is safe to embed in HTML <script> blocks. Pass true as the third argument to enable JSON_PRETTY_PRINT formatting. |
startsWith |
startsWith(str, prefix) |
true if str starts with prefix. |
endsWith |
endsWith(str, suffix) |
true if str ends with suffix. |
contains |
contains(haystack, needle) |
Substring check for strings; membership check for arrays. |
repeat |
repeat(str, times) |
Repeat a string N times. |
pad |
pad(str, length, pad?, side?) |
Pad to length. side: 'right' (default), 'left', 'both'. |
sprintf |
sprintf(format, ...) |
sprintf-style placeholder formatting. |
stripTags |
stripTags(str, allowed?) |
Strip HTML/PHP tags; allowed keeps specified tags. |
| Helper | Signature | Description |
|---|---|---|
join |
join(array, glue?) |
implode (default glue ''). |
keys |
keys(array) |
array_keys. |
values |
values(array) |
array_values. |
length |
length(value) |
String length (mb_strlen) or array count. |
count |
count(array) |
Alias for length. |
first |
first(array) |
First element, or null. |
last |
last(array) |
Last element, or null. |
slice |
slice(array, offset, length?) |
array_slice. |
reverse |
reverse(array|string) |
Reverse array or string (multibyte-safe). |
unique |
unique(array) |
Remove duplicate values. |
flatten |
flatten(array) |
Flatten one level deep. |
chunk |
chunk(array, size) |
Split into chunks of size. |
merge |
merge(a, b, ...) |
array_merge (variadic). |
sort |
sort(array) |
Sort ascending, re-indexed from 0. |
sortBy |
sortBy(array, key) |
Sort array of objects/maps by field key. |
range |
range(start, end, step?) |
Create a range array. |
min |
min(array) |
Minimum value. |
max |
max(array) |
Maximum value. |
sum |
sum(array) |
Sum of all values. |
avg |
avg(array) |
Arithmetic mean (returns 0.0 for empty arrays). |
shuffle |
shuffle(array) |
Return a shuffled copy. |
filter |
filter(array, value?) |
Remove falsy values; or keep only elements === value. |
map |
map(k1, v1, k2, v2, ...) |
Build an associative array from key/value pairs. Keys are DotPath expressions ('foo.bar' nests). |
list |
list(v1, v2, ...) |
Build an indexed array from the given values. |
store |
store(array?) |
Wrap an array in a mutable Store for chained .set() calls. |
Blate has no array-literal syntax ([...] is a subscript operator only), so the
three helpers above cover those use cases:
{# pass an associative array to a helper #}
{i18n('KEY', $map('name', user.name, 'count', total))}
{# DotPath keys produce nested arrays #}
{= json($map('user.name', user.name, 'addr.city', user.address.city))}
{# -> {"user":{"name":"..."},"addr":{"city":"..."}} #}
{# indexed array #}
{join($list(1, 2, 3), '-')} {# -> 1-2-3 #}
{# start from existing data, mutate, then pass on #}
{= json($store(defaults).set('extra', 1).getData())}
Keys passed to $map and $store().set() are full DotPath expressions — dots
create intermediate objects and bracket subscripts (items[0]) address array
indices.
| Helper | Signature | Description |
|---|---|---|
number |
number(n, decimals?, dec_point?, thousands_sep?) |
Format with thousands separator and decimal point. |
abs |
abs(n) |
Absolute value. |
round |
round(n, precision?) |
Round to decimal places. |
floor |
floor(n) |
Round down to integer. |
ceil |
ceil(n) |
Round up to integer. |
clamp |
clamp(n, min, max) |
Constrain to [min, max]. |
random |
random(min?, max?) |
Cryptographically secure random integer. |
| Helper | Signature | Description |
|---|---|---|
now |
now(microtime?) |
Current Unix timestamp; true returns float with microseconds. |
date |
date(date, format?, timezone?) |
Format a DateTimeInterface, timestamp, or date string. Default: 'Y-m-d H:i:s'. |
- Create a class extending
Blate\Features\Block. - Define
public const NAME = 'myblock';. - Override the lifecycle hooks you need.
- Call
Blate::registerBlock(MyBlock::NAME, MyBlock::class).
use Blate\Blate;
use Blate\Features\Block;
use Blate\Token;
class BlockAlert extends Block
{
public const NAME = 'alert';
public function getName(): string { return self::NAME; }
public function onOpen(): void
{
$this->lexer->nextIs(Token::T_TAG_CLOSE, null, true); // consume `}`
$this->parser->writeCode('echo \'<div class="alert">\';');
}
public function onClose(): void
{
$this->parser->writeCode('echo \'</div>\';');
}
public function requireClose(): bool { return true; }
}
Blate::registerBlock(BlockAlert::NAME, BlockAlert::class);Lifecycle hooks (all have default no-op implementations in the base class):
| Method | When it fires |
|---|---|
onOpen() |
Opening tag {@blockname ...} |
onClose() |
Closing tag {/blockname} |
onBreakPoint(TokenInterface $token) |
A {:name} tag inside the block |
onChildBlockFound(BlockInterface $block) |
A nested block opens |
onChildContentFound(TokenInterface $token) |
Raw text inside the block |
onChildExpressionFound(TokenInterface $token, bool $escape) |
An expression inside the block |
requireClose(): bool |
Return true to require {/blockname} |
use Blate\Blate;
Blate::registerHelper('slugify', function (string $str): string {
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($str)));
});Use in templates:
{slugify(article.title)} -- may be shadowed by user data
{$slugify(article.title)} -- guaranteed helper, ignores user data
{article.title | slugify} -- pipe filter: also guaranteed, ignores user data
The $ prefix and pipe-filter syntax always resolve against the helpers layer
only, so a slugify key in the template data cannot intercept the call.
Blate::scope() returns a BlateTemplateScope instance while a template is
being rendered. It throws BlateRuntimeException when called from outside any
active render call.
use Blate\Blate;
use Blate\BlateTemplateScope;
$scope = Blate::scope(); // BlateTemplateScope
$scope->data; // Blate\DataContext - full runtime scope stack
$scope->template; // Blate\Blate - the Blate instance for the running templateFor nested templates ({@import} / {@extends}) the scope stack grows, so
Blate::scope() always reflects the innermost currently executing template.
The primary use case is reading render data inside a helper without requiring the template author to pass extra arguments:
Blate::registerHelper('i18n', function (string $key) {
$locale = Blate::scope()->data->get('locale') ?? 'en';
return translate($key, $locale);
});{$i18n('WELCOME_MSG')}
Alternatively, pass $$ as an explicit argument to give a helper access to the
current DataContext directly, without the static scope stack:
{$i18n('WELCOME_MSG', $$)}
use Blate\DataContext;
Blate::registerHelper('i18n', function (string $key, DataContext $ctx) {
$locale = $ctx->get('locale') ?? 'en';
return translate($key, $locale);
});Global variables are values registered once at application bootstrap time and available in every template without being part of the per-render data. They sit between the helpers layer and the user data in the resolution stack, so user data can shadow them when needed.
| Name | Value | Notes |
|---|---|---|
BRACE_OPEN |
{ |
Literal opening brace |
BRACE_CLOSE |
} |
Literal closing brace |
BLATE_VERSION |
e.g. 1.1.0 |
Engine version string |
BLATE_VERSION_NAME |
e.g. Blate 1.1.0 |
Engine display name |
Outputting a literal brace without {@raw}:
Example code: {BRACE_OPEN}@if condition{BRACE_CLOSE}...{BRACE_OPEN}/if{BRACE_CLOSE}
Outputs:
Example code: {@if condition}...{/if}
Names must be valid Blate identifiers: a letter or underscore followed by
letters, digits, $, or underscores (APP_NAME, requestId, _debug).
An invalid name throws a BlateRuntimeException at registration time.
// Read-only constant (default) - throws if registered again
Blate::registerGlobalVar('APP_NAME', 'My App');
// With description shown in LSP hover / completions
Blate::registerGlobalVar('APP_NAME', 'My App', ['description' => 'The application display name.']);
// Editable - can be updated between renders
Blate::registerGlobalVar('REQUEST_ID', '', ['editable' => true]);
Blate::registerGlobalVar('REQUEST_ID', $requestId, ['editable' => true]);Use in templates exactly like any other variable:
<title>{APP_NAME}</title>
<footer>Powered by Blate {BLATE_VERSION}</footer>
User data with the same name takes priority over the global variable.
$global is a special chain-head that resolves a property directly from the global
vars layer, bypassing user data entirely. It is the mirror of $$ for global variables:
{APP_NAME} -- user data can shadow this
{$global.APP_NAME} -- always reads the registered global, never shadowed
The dot-chain can be as deep as needed:
{$global.APP_NAME}
{$global.THEME.color} -- if THEME is an object / associative array
This is useful in base layouts where render data from a child template may contain keys that coincidentally match global variable names.
A computed global has a factory callable instead of a static value. The factory is called on every template access — there is no memoization.
// Read-only computed global
Blate::registerComputedGlobalVar('NOW', fn () => date('Y-m-d H:i:s'));
// Editable computed global - factory can be replaced later
Blate::registerComputedGlobalVar(
'REQUEST_LOCALE',
fn () => Blate::scope()->data->get('locale') ?? 'en',
['editable' => true],
);Blate::getGlobalVars() returns the GlobalVarsContext singleton
(ArrayAccess). ->getNames() lists all registered names (static and
computed).
A .blate.php file placed next to composer.json in your project root is
the conventional place to register project-specific helpers, global variables,
and computed globals once. It is a plain PHP file that calls
Blate::register* methods:
<?php
// .blate.php
use Blate\Blate;
Blate::registerHelper('currency', fn (float $v) => '$' . number_format($v, 2));
Blate::registerGlobalVar('APP_NAME', 'My App');
Blate::registerComputedGlobalVar('NOW', fn () => date('Y-m-d'));Via autoLoad() in your application bootstrap:
// e.g. in public/index.php or config/blate.php
Blate::autoLoad(); // auto-discovers composer.json upward from getcwd()
Blate::autoLoad('/path/to/project'); // explicit rootautoLoad() returns true when the file was loaded, false when .blate.php
was not found or the project root could not be determined. Double-calls with
the same resolved path are silently skipped.
The LSP loads it automatically. When the language server receives the editor
workspace root it calls Blate::autoLoad($root) so that project-specific
helpers and global variables appear in completions and hover documentation
without any manual configuration.
The VS Code extension restarts the LSP automatically when .blate.php is
saved, created, or deleted, so changes to your config are reflected immediately
in completions and hover without reloading the editor window.
Blate::findProjectRoot(?string $start) walks upward from $start
(or getcwd() when null) until it finds a directory containing
composer.json. It returns the absolute path of that directory, or null
if none is found before the filesystem root.
Blate::getLoadedConfigs() returns a list<string> of the real paths of all
.blate.php files that have been loaded so far (via autoLoad() or any other
path). Useful for debugging or logging which config files are active:
foreach (Blate::getLoadedConfigs() as $path) {
echo "Loaded: $path\n";
}Any registered block or helper can be disabled at runtime without unregistering it. Disabled blocks and helpers retain their registration and can be fully restored with a single call.
A disabled block behaves as if it is not registered: any template that
references it will fail at compile time with an Unknown block name error.
// Disallow inline PHP blocks in user-supplied templates
Blate::disableBlock('php');
// Restore
Blate::enableBlock('php');
// Query
Blate::isBlockEnabled('php'); // false while disabledA disabled helper is excluded from the runtime helpers layer. Helper-only
lookups ({$name()} and pipe filters) will fail at render time with a
Helper "name" is not registered error. Plain-name lookups ({name()}) may
still resolve through user data if a matching key exists there.
// Remove a helper from template context
Blate::disableHelper('json');
// Restore
Blate::enableHelper('json');
// Query (the '$' prefix is accepted but optional)
Blate::isHelperEnabled('json'); // false while disabledBoth methods accept the helper name with or without the leading $ prefix.
Syntax-highlighting files for .blate templates live in the editors/
directory. Installation instructions per editor are below and also in
editors/README.md.
Local install (development / personal use):
cp -r editors/vscode ~/.vscode/extensions/blateRestart VS Code (or run Developer: Reload Window). .blate files are detected
and highlighted automatically.
Build .vsix:
npm install -g @vscode/vsce
cd editors/vscode
vsce package # produces blate-1.0.0.vsix
code --install-extension blate-1.0.0.vsixThe VS Code extension directory doubles as a TextMate bundle that JetBrains IDEs accept directly via the TextMate Bundles plugin (bundled since 2023.2):
Settings > Editor > TextMate Bundles > +- Select the
editors/vscode/directory. - Click OK and restart the IDE.
.blate files are recognised and highlighted without any further configuration.
# macOS
cp editors/sublime/Blate.sublime-syntax \
"$HOME/Library/Application Support/Sublime Text/Packages/User/"
# Linux
cp editors/sublime/Blate.sublime-syntax \
"$HOME/.config/sublime-text/Packages/User/"
# Windows (PowerShell)
Copy-Item editors\sublime\Blate.sublime-syntax `
"$env:APPDATA\Sublime Text\Packages\User\"Sublime Text detects .blate files automatically after the file is in place
(no restart required).
Publish to Package Control:
- Fork the package_control_channel repository.
- Add an entry pointing to this repository under
repository/b.json. - Open a pull request.
Manual install:
# Vim
cp editors/vim/syntax/blate.vim ~/.vim/syntax/
cp editors/vim/ftdetect/blate.vim ~/.vim/ftdetect/
# Neovim
cp editors/vim/syntax/blate.vim ~/.config/nvim/syntax/
cp editors/vim/ftdetect/blate.vim ~/.config/nvim/ftdetect/Via a plugin manager (recommended):
Add to your plugin manager config, pointing to this repository. For example:
" vim-plug
Plug 'silassare/blate', { 'rtp': 'editors/vim' }-- lazy.nvim
{ 'silassare/blate', config = false, opts = {}, dir = 'editors/vim' }
-- or directly:
{ dir = '/path/to/blate/editors/vim' }PHP syntax inside {~ ... ~} blocks is highlighted automatically when
$VIMRUNTIME/syntax/php.vim is present (standard Vim/Neovim distribution).
All three engines use the same fundamental strategy: compile to native PHP once,
cache on disk, and include the cached file on subsequent requests. Hot-path
render performance is functionally equivalent across all three.
| Aspect | Blate | Blade (Laravel) | Twig |
|---|---|---|---|
| Compiled output | PHP class extending BlateTemplateParsed |
Plain PHP file with echo/control-flow | PHP class extending Twig\Template |
| Cache key | content hash + file path + engine version | file path + mtime | source hash |
| Cache invalidation | file change OR engine version bump | file change | file change |
| Compile overhead | Minimal - single-pass lexer + parser | Medium - multiple compiler passes | Highest - full AST with visitor passes |
Blate has the lightest compile step of the three because it is a single-pass lexer/parser with no component resolver or service-container lookup.
All three engines inject loop metadata into the iteration scope. Blate and Twig
keep memory usage O(1) for streaming iterables (generators, database cursors);
Blade materialises the entire $loop object upfront.
| Feature | Blate | Blade | Twig |
|---|---|---|---|
| Basic iteration | {@each val in list} |
@foreach |
{% for %} |
| Key access | {@each val:key in list} |
$loop->index |
loop.key (assoc only) |
| Iteration index | {@each val:key:idx in list} |
$loop->index |
loop.index (1-based) |
| First / last flags | is_first / is_last (always in scope) |
$loop->first/last |
loop.first / loop.last |
| Memory for generators | O(1) - lookahead via Iterator::next()/valid() |
O(n) materialises | O(1) - native for loop |
| Loop of N with index | {@repeat n as i} + is_first/is_last |
@for |
{% for i in 0..n-1 %} |
| Empty-list fallback | {:else} branch on @each |
@forelse |
{% else %} inside {% for %} |
All three engines auto-escape HTML output by default, which is the most important XSS protection:
| Blate | Blade | Twig | |
|---|---|---|---|
| Auto-escape | {expr} -> htmlspecialchars() |
{{ }} -> e() |
{{ }} -> twig_escape_filter() |
| Raw / unescaped output | {= expr} |
{!! !!} |
{{ expr|raw }} |
| Inline PHP in templates | Yes - {~ ... ~} |
Yes - @php |
No |
| Sandbox for untrusted authors | No | No | Yes - restricts accessible methods/properties |
| Disable built-in blocks | Yes - Blate::disableBlock() |
No built-in mechanism | Partial via extension |
| Disable helpers | Yes - Blate::disableHelper() |
No built-in mechanism | Partial via extension |
Twig is the only engine with a proper sandbox, making it suitable for cases where template authors are untrusted (e.g., user-editable templates). Blate and Blade both allow escaping to full PHP when needed, which is powerful but means a malicious template author has full server access.
Blate's disableBlock() / disableHelper() API provides a lighter-weight
alternative: you can strip dangerous blocks like {@php} or restrict the
helper surface without a full sandbox.
| Feature | Blate | Blade | Twig |
|---|---|---|---|
| Inheritance | {@extends 'base' ctx}{@slot name}...{/extends} |
@extends + @section/@yield |
{% extends %} + {% block %} |
| Inclusion | {@import 'partial' ctx} |
@include |
{% include %} |
| Raw file embed | {@import_raw 'file'} |
@includeRaw (not built-in) |
N/A |
| Default slot content | {:default} breakpoint inside {@slot} |
@section with fallback |
{{ block() }} in child |
| Feature | Blate | Blade | Twig |
|---|---|---|---|
| Global variables | Blate::registerGlobalVar() |
View::share() |
$twig->addGlobal() |
| Computed global variables | Blate::registerComputedGlobalVar() (lazy, no memoization) |
No built-in | No built-in |
| Project config file | .blate.php auto-loaded by LSP; opt-in via Blate::autoLoad() |
AppServiceProvider (PHP) |
ExtensionInterface (PHP) |
| Custom helpers / filters | Blate::registerHelper() |
Custom directives / Blade X | $twig->addFilter/Function() |
| Inline array construction | $map(), $list(), $store() helpers |
PHP array literals in @php |
{} object / [] array |
| Pipe filters | {expr | helperName} (helper-only lookup) |
No native pipe syntax | {{ expr|filtername }} |
| Variable assignment | {@set name = expr} |
@php $name = expr; @endphp |
{% set name = expr %} |
| PHP literals in exprs | true/false/null (any case) |
Yes (full PHP) | Yes (true/false/null) |
| Render context in helpers | Blate::scope()->data / Blate::scope()->template or pass $$ |
No direct mechanism | No direct mechanism |
| Feature | Blate | Blade | Twig |
|---|---|---|---|
| Syntax highlighting | VS Code, Sublime Text, Vim/Neovim | VS Code (official ext.) | VS Code + many editors |
| Language server (LSP) | Built-in PHP LSP server | No official LSP | No official LSP |
| Parse diagnostics | Exact line/column squiggles | No | No |
| Shadow warnings | Unqualified helper and global-var access | No | No |
| Unknown ref errors | $global.UNKNOWN and $noSuchHelper() flagged |
No | No |
| Completions | Blocks, helpers, global vars, in-scope variables | Snippets only | No |
| Hover documentation | Helpers and global variables with descriptions | No | No |
| Variable rename | In-document rename | No | No |
| Quick fix | Prepend $ to shadowed helper calls |
No | No |
| Blate | Blade | Twig | |
|---|---|---|---|
| Hot render speed | Fast | Fast | Fast |
| Cold compile speed | Fastest | Medium | Slowest |
| Memory footprint (iteration) | O(1) | O(n) for @foreach |
O(1) |
| Auto-escaping | Yes | Yes | Yes |
| PHP literals in expressions | Yes | Yes (full PHP) | Yes |
| Sandbox for untrusted authors | No | No | Yes |
| Disable blocks/helpers | Yes | No | Partial |
| Global variables | Yes | Yes (View::share) |
Yes (addGlobal) |
| Computed global variables | Yes | No | No |
| Project config file | .blate.php |
AppServiceProvider |
Extension class |
| Built-in LSP server | Yes | No | No |
| Framework coupling | None - standalone | Laravel only | Framework-agnostic |
| Feature richness | Focused | Rich (Livewire, components) | Rich (macros, extensions, tests) |
Choose Blate when you want a fast, lightweight, framework-agnostic engine with a composable security surface, and templates are written by trusted developers.
Choose Twig when templates may be written by untrusted users, or you need the sandbox and the extension ecosystem.
Choose Blade when you are already in Laravel and need native integration with components, Livewire, etc.