lendable/twig-bundle
A Twig templating bundle for Laravel that renders .twig views alongside Blade, configures template paths, registers custom extensions, and caches compiled templates. It keeps Laravel’s view workflow while offering Twig’s concise syntax, ideal for teams migrating from Symfony or preferring a flexible...
Read the description twice before you composer require anything. lendable/twig-bundle is a Symfony bundle, not a Laravel package. It ships a Bundle subclass, a DI extension, and Symfony container definitions. composer require lendable/twig-bundle in a Laravel app gets you autoloadable classes and nothing else — no auto-discovery, no service provider registration, no Twig environment wiring. If your app is Blade/laravel/framework, the adoption decision is really "fork the concept, not the code."
Look at these files first, in this order:
Twig\Loader\LoaderInterface — this is the portable asset. It resolves a template name to source (getSourceContext), reports freshness (isFresh), produces a cache key (getCacheKey).Extension — this shows how the loader is registered into Twig and what config values it accepts.The first use case in a Laravel codebase is a DatabaseTwigLoader you write yourself, backed by an Eloquent model, registered into a rcrowe/twigbridge environment. Minimal shape:
class DatabaseTwigLoader implements \Twig\Loader\LoaderInterface
{
public function __construct(private \Illuminate\Database\ConnectionInterface $db) {}
public function getSourceContext(string $name): \Twig\Source
{
$row = $this->db->table('twig_templates')->where('name', $name)->first();
if (! $row) {
throw new \Twig\Error\LoaderError("Template [$name] not found.");
}
return new \Twig\Source($row->body, $name, $row->updated_at);
}
public function getCacheKey(string $name): string
{
$updated = $this->db->table('twig_templates')->where('name', $name)->value('updated_at');
return $name . ':' . ($updated ?? '0');
}
public function isFresh(string $name, int $time): bool
{
$updated = $this->db->table('twig_templates')->where('name', $name)->value('updated_at');
return strtotime((string) $updated) <= $time;
}
public function exists(string $name): bool
{
return $this->db->table('twig_templates')->where('name', $name)->exists();
}
}
Register a ChainLoader with your database loader after the filesystem loader so file-based templates win and DB rows act as overrides. That gives you a safe migration path: unresolved names still fall back to Git-tracked files.
Do not attempt the Symfony path (config/bundles.php, DI tags, Doctrine fixtures) unless you're actually running Symfony. In Laravel, that is a day burned on wiring a framework you don't use.
Chain loader for override + fallback. The single most useful pattern: new ChainLoader([$filesystem, $database]). File templates are the default and Git-reviewable; DB rows override specific paths (e.g. emails/welcome.twig) that product/marketing wants to change live. Resolution precedence is positional — put the DB loader last so a filesystem template named the same thing always wins during the transition period.
Per-tenant scoping at the query, not the name. If you take one architectural cue from the assessment-PM notes: decide early whether overrides are per-tenant rows or per-environment config. Multi-tenant rows scoped by tenant_id with a where('tenant_id', ...) on every query is the durable choice; a per-tenant name prefix (tenant42::emails/welcome) leaks the tenant into the filesystem namespace and gets ugly fast.
Cache compiled output, invalidate on write. Every getSourceContext / isFresh in the naive loader hits the DB. Wrap the source read in Cache::remember("twig:$tenant:$name", now()->addMinutes(15), ...) and, on save, Cache::forget. In single-instance apps, forget the key on the model's saved event. In multi-instance apps, use a versioned cache key: store a per-template version integer, fold it into the key, and bump on save. That avoids broadcast invalidation entirely and survives cache backend quirks.
Register through TwigBridge, not ad hoc. Don't construct a Twig\Environment by hand. In a service provider:
public function register(): void
{
$this->app->extend('twig.loader', function ($loader, $app) {
return new \Twig\Loader\ChainLoader([
$loader,
$app->make(DatabaseTwigLoader::class),
]);
});
}
Keep the DB loader container-resolved, not new-ed inline, so it picks up the same DB connection and cache store as the rest of the app.
Write a template model with validation on save. Twig has no "compile check" the way PHP does. On saving, run $twig->parse($twig->tokenize(new Source($body, $name))) in a try/catch and reject the write on Twig\Error\SyntaxError. That one hook prevents the "user saved a typo, whole site white-screens" failure mode, which is the failure mode that kills these features in practice.
Version every save. An append-only twig_template_revisions table (template_id, body, created_by, created_at) is ~20 lines and gives you rollback. Rollback is the first thing support asks for the moment a bad template ships — and there is no deploy to roll back to.
Preview before publish. Render to a preview route with tenant/user context stubbed, not in production traffic. Preview is a rendering endpoint with real authZ, not a debug page.
CLI commands you will want on day one:
php artisan twig:list — list DB templates, tenant, last updated.php artisan twig:validate — parse every row, exit non-zero on syntax errors. Wire into CI.php artisan twig:purge — clear the compiled cache for one or all templates.These are the Artisan equivalents of whatever console commands the bundle ships. Reimplementing them takes an afternoon and makes the feature operationally sane.
Feature flag the whole thing. Gate DB-loader resolution behind a config flag defaulting off, with filesystem fallback always on. Roll out per tenant. If the DB is down or a template is bad, you flip the flag and you're back on Git-tracked files with no deploy.
DB-stored Twig is remote code execution. Twig is not sandboxed by default; whoever can write to twig_templates can {{ system('...') }} if a function is exposed. Admin-panel authZ, audit logging, and — seriously — the twig/sandbox-extension are not optional. The sandbox has its own sharp edges (allowed tags/filters/methods must be enumerated; anything you forget to allow breaks templates, anything you allow too broadly is a new hole). Treat template editing as privileged access equivalent to a deploy.
isFresh semantics are where naive loaders rot. Three mistakes to avoid:
true always caches forever — edits silently don't apply, eroding trust in the feature.false always re-reads the DB on every render, including high-fanout pages and loops.updated_at string comparison without strtotime — timezone and format differences make it lie intermittently.Test both: an edit invalidates the compiled cache, and a repeated render does not hit the DB.
Cache stampede on popular layouts. A shared layouts/base.twig invalidated under production traffic can cause dozens of simultaneous recompilations. Add jitter or use a short lock (Cache::lock) around compilation of widely used templates.
DB unavailability takes the site down. If the DB loader is the only loader, a DB blip means no templates render. The ChainLoader with filesystem-first is your resilience story, plus a warm compiled cache. Make sure your loader's exists() fails closed (returns false) rather than throwing on connection errors, so the chain loader falls through to files.
Twig major-version alignment. The bundle's loader was written against whichever Twig major it pins. getCacheKey / SourceContext signatures changed across Twig 1 → 2 → 3. Pin TwigBridge and Twig explicitly, run the same loader tests across the versions you support. A loader written for Twig 2 will silently misbehave or fatal under Twig 1.
Namespace collisions and precedence. Decide and document: do DB rows override filesystem templates with the same name, or only fill gaps? ChainLoader uses array order — get this wrong and a marketing edit appears to "not work" because a stale filesystem copy of emails/welcome.twig is being served. Log at debug level which loader resolved each template during rollout.
Names like @Bundle/template.twig need care. Symfony's bundle-namespaced template syntax doesn't mean anything to a Laravel-side DB loader. If you need namespaced lookups, do the resolution explicitly and normalise the name at the boundary before querying.
Two template engines is real overhead. Blade and Twig can coexist via TwigBridge, but you get separate escaping rules, no shared directives, separate linters, and no @vite/@livewire in Twig. Keep Twig scoped to the specific surfaces (transactional email, notifications, PDF layouts) where runtime editing is the actual requirement — not as a parallel view layer for the whole app.
Testing the loader. Unit-test getSourceContext, getCacheKey, and isFresh against an in-memory SQLite fixture. Integration-test one route rendering a DB-backed template end-to-end, plus a "template deleted from DB falls back to filesystem" case. These three tests catch 90% of the bugs this pattern produces.
Fork and pin. Zero stars, zero dependents, likely single-maintainer. If you vendor any of the source, vendor it into your own repo with a license file and a note on provenance. Don't add it to composer.json as a runtime dependency you expect anyone to maintain for you.
Don't build a CMS by accident. The loader is the easy part. Editor UI, roles, preview, versioning, audit, scheduling, and localization are the remaining 90%. If those are real requirements, evaluate a headless CMS or a marketplace solution before growing one from a loader — a bespoke CMS is a recurring maintenance line item, not a one-quarter project.
How can I help you explore Laravel packages today?