Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Twig Bundle Laravel Package

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...

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The class implementing Twig\Loader\LoaderInterface — this is the portable asset. It resolves a template name to source (getSourceContext), reports freshness (isFresh), produces a cache key (getCacheKey).
  2. The bundle class and its Extension — this shows how the loader is registered into Twig and what config values it accepts.
  3. The Doctrine entity + migration — this is your schema, and you will own it forever once you copy it.

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.

Implementation Patterns

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.

Gotchas and Tips

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:

  • Returning true always caches forever — edits silently don't apply, eroding trust in the feature.
  • Returning false always re-reads the DB on every render, including high-fanout pages and loops.
  • Using 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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle