kwrz/twig-exception
Integrates Twig with Laravel's exception handler so errors and debug pages render through Twig templates instead of Blade, keeping error output consistent with your Twig-based views and making custom error pages easy to style.
Install via Composer alongside your Twig setup:
composer require kwrz/twig-exception
The package is a thin layer: it registers an exception listener that maps an HTTP status code (or a specific route) to a Twig template, renders it, and returns a Response with the correct status. It does not replace your framework's error handling wholesale — it intercepts and gives you a template hook.
First place to look is the package's service provider / registration entry point (for Silex-style apps, a ServiceProviderInterface implementation; for Symfony, a bundle class). That file shows you the constructor options: the Twig environment/service ID, the template path root, and the default fallback template. Read it before the README — for small packages like this, the provider is the documentation.
Minimal wiring, conceptually:
// register the provider, pointing at your error templates
$app->register(new Kwrz\TwigException\Provider(), [
'twig.exception.path' => __DIR__ . '/../views/errors',
'twig.exception.debug' => false,
]);
Then drop in 404.html.twig, 500.html.twig, etc.
First use case: replace the framework's bare "Not Found" text with a branded 404 page. That's the 10-minute win, and it's also the best smoke test that the listener is actually winning the race against the framework's native handler.
Status-code map as the primary contract. The core pattern is a lookup table from status code → template name. Keep that mapping in one config file, not scattered. Your 404 and 500 pages should extend a shared errors/layout.html.twig so the branding lives in one place:
{# errors/404.html.twig #}
{% extends 'errors/layout.html.twig' %}
{% block title %}Page not found{% endblock %}
{% block body %}
<h1>We couldn't find that page.</h1>
<p><a href="{{ path('home') }}">Back to the homepage</a></p>
{% endblock %}
Route-specific overrides. The more interesting pattern: some routes deserve a bespoke error page — a checkout flow's 404 should offer "continue shopping," an API-adjacent route should offer docs links. Register those per-route and let the status-code map handle everything else as a fallback. Order matters: route matches should be checked before the generic status-code lookup.
Pass useful context into the template, not the exception. Expose a small, curated variable set (status code, a safe message in production, a correlation/request ID). Do not expose exception.getMessage() or traces on 5xx in production — that's the exact mistake this package makes easy to commit. Tie the message to the debug flag.
Debug vs. production split. In production you want your Twig page; in development you want the full stack trace. Most frameworks swap in Whoops/Flame in debug mode. Verify the package respects that boundary, or gate your registration on debug === false so developers don't lose traces.
Logging and monitoring. The listener should log the exception before rendering. Wire the logger so your error tracker (Sentry, etc.) still receives 5xx events — a common regression when you take over the error handler is silently swallowing failures because the custom page renders "successfully."
Content negotiation. If you serve JSON, check the Accept header (or the route prefix) and return a JSON error body instead of the Twig page. Doing this once, centrally, saves you from a htmlspecialchars-escaped HTML blob inside a JSON API response.
debug before you check your code.204, 304, or HEAD responses to a template — a body on those is a protocol violation and some clients will choke.path() call for a route that itself 404s), you get either a loop or a blank 500. Keep error templates dependency-light: no DB queries, no dynamic route generation you can't guarantee. Test them by deliberately rendering with a broken context.path() in a 404 page can be a hidden trap if the router itself failed. Prefer hardcoded URLs or a url() to a static route for error-page navigation.composer.json, read the provider source end-to-end (it's small), and be prepared to replace it with ~40 lines of your own listener if it goes unmaintained. The abstraction is thin enough that this is a cheap exit.How can I help you explore Laravel packages today?