salesforce/handlebars-php
Handlebars.php is a PHP port of Handlebars.js, bringing logic-less, Mustache-inspired templating to PHP. It supports custom helpers, partials, and context-aware rendering, so templates can be shared between JavaScript and PHP with consistent output.
Install
composer require salesforce/handlebars-php
First look
src/Handlebars/Handlebars.php — the engine facade; everything hangs off this class.src/Handlebars/Template.php — compiled template object; render() and renderContent() are the entry points.src/Handlebars/Context.php — how paths like user.name and ../parent are resolved inside helpers.src/Handlebars/Helpers.php — built-in helpers (if, unless, each, with) and how helpers are resolved.src/Handlebars/Loader/ and src/Handlebars/Cache/ — where templates come from and how compiled output is cached.Minimal render
use Handlebars\Handlebars;
$engine = new Handlebars();
echo $engine->render('Hello {{name}}', ['name' => 'World']);
// Hello World
First real-world use case
The strongest fit is user-authored or database-stored templates: notification emails, SMS bodies, invoice line items, exported reports — anywhere you want non-developers to edit copy without touching PHP. Handlebars is logic-less, so a bad template degrades gracefully instead of executing arbitrary code. For a first integration, render a single email body from a DB column:
echo $engine->render($notification->body_template, [
'user' => ['first_name' => $user->first_name],
'order' => ['number' => $order->number, 'total' => $order->formatted_total],
'items' => $order->items->toArray(),
]);
Then graduate to a FilesystemLoader once templates become files.
String templates vs. filesystem templates
For small, dynamic strings (DB templates, CMS snippets) use the default StringLoader and render($templateString, $data).
For view files, swap the loader:
use Handlebars\Handlebars;
use Handlebars\Loader\FilesystemLoader;
$engine = new Handlebars([
'loader' => new FilesystemLoader(__DIR__.'/views', ['extension' => '.hbs']),
'partials_loader' => new FilesystemLoader(__DIR__.'/views/partials', ['extension' => '.hbs']),
]);
echo $engine->render('emails/welcome', $data); // loads views/emails/welcome.hbs
Compile once, render many
compile() returns a reusable Template; keep it and render repeatedly. This matters in loops (foreach ($users as $u) { echo $tpl->render($u); }) and in queue workers.
$tpl = $engine->compile($body);
foreach ($recipients as $r) {
$out[] = $tpl->render(['recipient' => $r]);
}
Custom helpers — the main extension point
Helpers receive the template, the current context, and the raw argument string. You resolve arguments yourself against the context:
$engine->addHelper('money', function ($template, $context, $args) {
return '$' . number_format((float) $context->get(trim($args)), 2);
});
$engine->addHelper('shout', function ($template, $context, $args) {
return strtoupper((string) $context->get(trim($args)));
});
Register a small, deliberately closed set of helpers (money, date, urlencode, t, pluralize). This is where you keep template authors from needing logic.
Block helpers / partials for composition
{{> partialName}} for reused fragments (headers, footers, address blocks).$engine->registerPartial('name', $source), or let partials_loader resolve from a directory.{{#each}}, {{#if}}, {{#with}}) rather than inventing control flow in PHP; the whole point is that the template declares the shape.Escaping and safe output
{{var}} escapes by default. For HTML fragments produced by a helper, return a SafeString so they aren't double-escaped:
use Handlebars\SafeString;
$engine->addHelper('nl2br', function ($t, $c, $a) {
return new SafeString(nl2br(htmlspecialchars((string) $c->get(trim($a)))));
});
Caching in production
use Handlebars\Cache\Disk;
$engine = new Handlebars([
'loader' => new FilesystemLoader(__DIR__.'/views', ['extension' => '.hbs']),
'cache' => new Disk(sys_get_temp_dir().'/hbs-cache'),
]);
Disk caching avoids re-parsing templates on every request. Treat the cache directory as disposable and clear it on deploy.
Laravel / framework integration
Bind a configured engine as a singleton in a service provider and inject it into mailers/notifiers. Keep Blade for developer-owned layouts and reserve Handlebars for content users own (email templates, message bodies, PDF sections). A single helper set shared across the app keeps output consistent, and a TemplateRepository (DB or filesystem) becomes the only place that knows how templates are stored.
Custom loader pattern
The cleanest way to support DB-backed templates is a custom LoaderInterface whose load($name) fetches the row and returns its body. The engine, helpers, caching, and partials then work exactly as with files.
Escaping is on by default. {{bio}} HTML-escapes. Use {{{bio}}} or return a SafeString from helpers. Getting this wrong in email templates is the most common surprise.
Missing variables fail silently. A typo like {{user.frist_name}} renders an empty string, not an error. Add a build-time check (render with sample data and assert non-empty) or a debug helper that logs unresolved paths.
Helper arguments are raw strings, not evaluated values. {{shout user.name}} passes "user.name" as $args; you must call $context->get(trim($args)) yourself. For multi-arg helpers, split and trim explicitly — there is no argument array.
{{else}} / inverse blocks can differ from Handlebars.js. Block helper behavior and inverse rendering in this port have varied by version. Verify {{#if}}...{{else}}...{{/if}} behavior against your installed release, and prefer keeping block structure simple. If you need complex branching, move it into a PHP-side data shape instead.
Truthiness is not PHP's. Empty strings, 0, "0", empty arrays, and null are falsy; but the exact rules inside {{#if}} can surprise you versus empty(). Normalize data (booleans, non-empty strings) before passing it in.
Helper output is not sandboxed. Helpers run plain PHP. Templates may be logic-less, but any helper you expose is a capability. Never expose file/exec/system helpers to user-editable template sources.
Path notation quirks. Dot paths (a.b.c), this, @index, @key, and ../ parent traversal are supported, but mixing them with missing intermediate keys yields empty output rather than an error. Normalize nested arrays before rendering.
each over associative arrays. Iteration works, but @key/@index availability and ordering depend on the PHP array — sort in PHP first if output order matters.
Namespace/API drift. Older releases used underscore classes (Handlebars_*); current code uses the Handlebars\ namespace. Pin a version and consult that version's README/tests, since the last tagged release is January 2023 and semantics have shifted across minor versions.
Disk cache keys and invalidation. Cache is keyed by template identity/source. If you edit a template file and see stale output, clear the cache directory — and make sure it happens in your deploy step (or in a test setup that renders many templates).
Partial resolution differs between loaders. Partials registered by string and partials resolved by partials_loader follow different lookup rules; a partial that works inline may not resolve from a file (and vice versa). Standardize on one mechanism per project.
Performance checklist: compile once and reuse; use FilesystemLoader + Disk cache in production; avoid re-instantiating Handlebars per render; keep helper bodies free of I/O because helpers run per-invocation inside loops.
Maintenance posture. The package is stable and MIT-licensed but has a slow release cadence. It's a fine choice for logic-less templating and user-authored content; if you need active feature development or security responsiveness, wrap it behind a thin TemplateRenderer interface so you can swap engines later without touching call sites.
How can I help you explore Laravel packages today?