sytxlabs/blade-sandbox
Blade Sandbox provides a safe, isolated environment for rendering untrusted Blade templates in Laravel. It helps preview user-supplied views while limiting access to app data and risky directives, ideal for CMS previews, template builders, and multi-tenant apps.
Read SECURITY.md before anything else. This package's value is its threat model, and the threat model defines what you can and cannot promise your users. The README is the API reference; SECURITY.md tells you whether your use case is even in scope. Do that before you write a line of policy config.
Install:
composer require sytxlabs/blade-sandbox
php artisan vendor:publish --tag=blade-sandbox-config # optional
PHP 8.2+ with ext-ctype, ext-dom, ext-libxml, ext-tokenizer. You need illuminate/support|view|contracts|filesystem 10–13 — note laravel/framework itself is not required, so this drops into slim or non-standard Laravel deployments. Livewire is optional; if you use it, the package supports both Livewire 3 and 4.
Target Laravel 12 or 13. Laravel 10 and 11 are marked EOL in the compatibility table. If you're greenfield, this is a non-decision; if you're on 10/11, budget the upgrade alongside adopting this package rather than after.
Your first use case should be the smallest one you actually have. The canonical one: a tenant or user supplies a Blade snippet stored in the database (notification template, invoice footer, marketing e-mail body), and you render it server-side. Start there.
The mental model that matters on day one: nothing works until you allow it. A template of Hello {{ $user->name }} will fail until your policy allows the name property on your user DTO. This is not a bug and it is not a misconfiguration. Expect your first hour to be spent in the deny path, reading errors, and adding allows — that is the package working correctly.
Because of that, wire up the authoring loop before you wire up the render loop:
validate() (or php artisan blade-sandbox:check) and surface the errors in the editor.Doing it in that order means authors get a usable error message instead of a 500 at send time, which is the single biggest determinant of whether this package feels good or miserable in production.
Design policies around use cases, not around data. A named policy per surface — marketing-email, tenant-invoice-footer, user-profile-bio — beats one permissive policy shared across all of them. Policies live in config/blade-sandbox.php or can be defined in code via definePolicy(). The moment two unrelated features share a policy, you'll start widening it for one feature's needs and silently widening it for the other's.
Lean on extends for the common base, then tighten. A base policy holding the universally safe surface (a few __toString-able value objects, a handful of vetted safe helpers, @if/@foreach) extended by narrower policies keeps you from copying the same allow-list five times. Deny rules always win over allows, so you can extend a base and then deny the two functions that particular surface has no business touching.
Per-tenant and per-user resolution is where the real design work is. If tenants can author templates, the resolution callback that maps tenant → policy is a security-critical piece of your application. Treat it as such: unit-test it, keep it small, and never let it fall back to a permissive default when the tenant is unknown. Fail closed.
Expose DTOs, not Eloquent models. The package supports DTOs with their own public API exposure, which means you control exactly what a template can reach. Handing templates a raw Eloquent model is how you end up discovering a relationship named user that recursively exposes a password hash. Bind a purpose-built DTO with only the fields the template legitimately needs. Carbon instances, enums, Collections and similar value objects are vetted by default, so the DTO doesn't have to flatten everything into strings.
Match the template loader to the storage. File views, database-backed loaders and API-backed loaders are all supported, plus bound namespaces so templates can resolve x-tenant::foo-style paths. For anything user-authored and persisted, turn on the integrity manifests (SHA-256 + HMAC-signed). That moves you from "the DB row is trusted" to "the DB row is signed by us," which is the difference between a SQL-injection-adjacent bug being survivable and being a full sandbox escape.
For Livewire, the compile-time checks are the floor, not the ceiling. wire:* and Alpine checks at compile time stop a template from wiring an arbitrary public method. They do not stop a browser from POSTing directly to a Livewire endpoint. The server-side request guard for guarded components is the actual boundary — make sure every component that renders sandboxed templates is registered as guarded. If you skip this, you have a nice authoring experience and no security property.
On output: enable an allowlist HTML sanitizer for anything rendered into a browser-facing view, and use the plain-text conversion for e-mail bodies. The sanitizer is a second layer after the sandbox; both should be on for user-authored HTML. For e-mails, the plain-text path is often the entire output contract — validate against it, not just the HTML.
Queue and isolate heavy renders. Cooperative limits (iterations, timeout, memory, output size, depth) catch accident and most abuse, but they're cooperative — a genuinely hostile template is best handled by the isolated separate-process renderer. Wire the queue driver for anything that runs at send-time in a web request; a slow template should never hold a PHP-FPM worker.
Use preview() for authoring UX. It's explicitly non-throwing, which is what you want in a live editor where the author is mid-keystroke and the template is half-written. Reserve real rendering for the save/send path. validate() for "is this syntactically and structurally sound," preview() for "show me roughly what this looks like," real render for the actual output.
Instrument it. Structured logging and events are exposed for a reason. Log every render with the policy name, template identifier, tenant, and outcome. When a tenant asks "why did my e-mail come out wrong," you want the answer in your logs, not a reproduction attempt.
The default-deny wall is the main onboarding cost. {{ $order->customer->email }} needs an allow for the customer property and the email property, on the right types. Read the error messages carefully — they name the exact operation being denied. Budget real time for the first policy; subsequent ones are fast because you'll be copying patterns.
The compiled template cache is keyed by policy fingerprint. That's the right design and it means policy changes invalidate correctly. But it also means a policy change is effectively a cache-flush event for the affected surface. On a large template corpus, expect the first renders after a policy deploy to be slower. Don't ship a policy change with a traffic spike; and don't assume a cache "warm-up" script written against the old fingerprint still applies.
The cache key also includes compiler version and Livewire major version. Upgrading Livewire 3 → 4, or bumping the package, invalidates compiled templates. That's correct behaviour, but it means a Livewire major upgrade is a render-performance event, not just a dependency bump. Sequence it accordingly.
Cooperative limits are not a security boundary. They are described as cooperative and they are. Iteration counts and timeouts inside the render loop are best-effort — a template doing something pathological in a single native call won't be pre-empted by them. If your threat model includes deliberately malicious template authors (rather than careless ones), the isolated separate-process renderer is mandatory, not optional. Read the "what's out of scope" section of SECURITY.md and make sure your product promises nothing beyond it.
learn() and blade-sandbox:policy are development tools. Policy learning is a great way to bootstrap a policy from a set of known-good templates, and a catastrophic way to run in production — it will happily allow whatever it observes. Generate the policy locally, review it as a diff, commit it. Never let a runtime path call learn().
Author lockout is a real setting with a real failure mode. If a template loops infinitely or trips limits repeatedly, the author lockout protects the system by locking that author out. Make sure locked-out authors get a comprehensible message and a support path, otherwise you'll get tickets that read "the e-mail editor is broken" with no further detail.
Fallback-on-error rendering is a silent-failure factory. A template that fails and renders a fallback "looks fine" in the UI while producing wrong output. Always emit an event or log line when the fallback fires, and surface it in your admin tooling. This is the single easiest way to run a sandboxed renderer badly.
HMAC manifest keys need a rotation plan. If you sign stored templates, decide now what happens when the signing key rotates: re-sign before rotating, keep the old key for verification during a window, and make a verification failure loud rather than a silent re-sign. Signing without a rotation story tends to mean the key never rotates.
The extension points (configure(), definePolicy(), extendPolicy()) are for package and platform code, not for feature code. If you find yourself calling them from a controller or a Livewire component, you're building policy in the wrong place — move it to a service provider or a dedicated policy class so it's boot-time and testable.
Test your policies, not just the package. 900+ tests and compiler fuzzing at 3000–5000 malicious templates per seed tell you the sandbox is solid; they tell you nothing about whether your allow-list is too wide. Write security-regression tests asserting that specific templates you consider illegitimate fail to render — the negative tests are the ones that catch a widening you didn't intend. The provided test fakes make this cheap.
Watch for a version-drift trap with Livewire 4 on Laravel 13. The table pins Livewire 4 to ≥ 4.2.0 on Laravel 13 and Livewire 3 to ≥ 3.7.11. Older Livewire minor versions in those combinations are not listed as supported — check your composer.lock, not your composer.json, before assuming you're fine.
How can I help you explore Laravel packages today?