jolicode/gif-exception-bundle
Symfony bundle that turns exceptions into animated GIFs. Instead of a plain error page, it displays a random GIF alongside the exception message and stack trace, making debugging and error pages more fun. Quick to install and configure in any Symfony application.
First, the blunt part: this is a Symfony bundle, not a Laravel package. There is no service provider, no kernel.exception event wiring for Laravel, and no config publishing. composer require --dev jolicode/gif-exception-bundle in a Laravel app will install Symfony components you already have transitively and then do exactly nothing. Treat this assessment as "what it does, whether the idea is worth stealing, and how to get the same result in Laravel."
The v1.12.0 release changes nothing about that verdict. It is Symfony-side housekeeping — Symfony 8.2 support, a PHPUnit 11 upgrade, a Castor task replacing the GIF optimizer command, a fix to exception status-code handling, extra tests for non-HTTP exceptions and disabled debug mode, and a move to the standard bundle directory structure. The relevant part for a Laravel developer is that the project is alive and maintained, which makes the pattern worth copying but the package still irrelevant to install.
For a Symfony app (or a polyglot team where you own one), the setup is genuinely minimal:
composer require --dev jolicode/gif-exception-bundle
Register it for dev only in config/bundles.php:
return [
// ...
JoliCode\GifExceptionBundle\GifExceptionBundle::class => ['dev' => true],
];
Then throw something from a controller and reload. Where to look first, in order:
src/DependencyInjection/Configuration.php — the authoritative list of options (GIF source/provider, API key, environment gating). Note: v1.12.0 moved to the standard bundle directory structure, so the class sits under src/ rather than at the bundle root. The README drifts; the config tree doesn't.kernel.exception — that's the whole trick: it intercepts the exception before Symfony's default ErrorListener renders the HTML error page, and renders its own Twig template instead. v1.12.0 added explicit tests for the debug-disabled path, so this listener now has documented, asserted behaviour when debug is off — read those tests, they're the clearest spec of the fallback.First realistic use case: a local dev environment where a 500 during frontend work costs you ten seconds of squinting at a stack trace. The GIF page keeps the status code and message while making the page memorable. That's the entire value proposition — it is a joke tool with a real debugging-ergonomics edge.
For Laravel, the equivalent first step is to check what you already have: Laravel 8+ ships Spatie Ignition as the default debug page, and it already supports custom tabs and "solutions." If you just want a fun page, you extend Ignition, not this bundle.
Dev-only registration, enforced twice. In Symfony, register the bundle under ['dev' => true] in bundles.php, and keep the app's APP_ENV honest. In Laravel ports, gate on both the environment and debug flag — they are not the same thing and staging usually has one but not the other:
// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e, Request $request) {
if (! app()->environment('local') || ! config('app.debug')) {
return null; // fall through to the normal handler
}
if ($request->expectsJson()) {
return null; // never break API clients with HTML
}
return response()->view('errors.gif', ['exception' => $e], $this->statusFor($e));
});
});
Returning null from a render callback is the Laravel equivalent of listener priority: it says "not mine, keep going."
Preserve the exception's status code, don't hardcode 500. v1.12.0 shipped a fix titled "Fix exception code handling," which is a useful reminder: the bundle now renders the exception's own code (404, 403, 422) rather than assuming every throw is a 500. Mirror that — a ModelNotFoundException should still surface as a 404 with a GIF, not get flattened to a 500. Derive the status from the exception (via HttpExceptionInterface::getStatusCode() in Laravel, or a small mapper for domain exceptions) and only default to 500.
Steal the architecture, not just the joke. The bundle's structure is a small, clean pattern worth copying: a GifRepository-style interface with two implementations — one that resolves from a local vendored GIF set, one that calls a remote API. Bind the local one by default and the API-backed one only when a key is configured:
interface GifRepository { public function forException(Throwable $e): string; }
class LocalGifRepository implements GifRepository { /* pick from resources/gifs */ }
class RemoteGifRepository implements GifRepository { /* HTTP call + cache */ }
This is what makes the difference between a toy that works on a plane and one that hangs your error page for 2s on a flaky network.
Map the exception to the GIF deterministically. Hash the exception class name into the GIF array rather than random_int(). Reasons: the same bug shows the same GIF (useful pattern recognition during a debugging session), and functional tests that snapshot the page stay stable. Note that v1.12.0's test additions — non-HTTP exceptions, disabled debug — are exactly the kind of cases your own deterministic mapper needs to handle without throwing a second exception inside the error renderer.
Wire it as middleware-free as possible. In Laravel, exception rendering belongs in withExceptions, not in a middleware try/catch. Middleware won't catch errors thrown during container boot or in other middleware that runs before yours. The bundle gets this right by hooking the kernel event; mirror that by staying in the exception handler.
Integration tip for mixed stacks: if you run Symfony and Laravel services side by side, standardise on one "fun debug page" policy per environment. Two teams inventing two different 500 pages on the same shared staging host is how you end up debugging a debug page.
DependencyInjection/, Resources/, and friends are now under src/ and templates/. Any bookmarked GitHub URLs, docs, or IDE breakpoints referencing the old root-level layout will 404 or point at nothing. Same code, new address.bin/console debug:event-dispatcher kernel.exception shows the exact order — do that before touching config.test env is where it bites. Any functional test asserting on a 500 response body will now assert against a Twig template full of <img> tags. Either restrict the bundle to dev only (not dev + test), or assert purely on status code and headers. v1.12.0's new tests around disabled debug mode give you a model for the assertions that survive: boot with debug off, assert a plain response..env.local, never in a committed file, and always implement a local fallback so a rate-limited or offline environment still renders a usable stack trace.APP_DEBUG=false and asserts a plain JSON 500 — cheap insurance, and now literally the scenario upstream tests.templates/bundles/…; in a Laravel port, just publish your own view. There's no rich configuration surface here, and expecting one wastes time — read Configuration.php once and move on.bin/console cache:clear before concluding it's broken; container rebuilds don't always happen on the fly for bundle registration changes, and the v1.12.0 directory move makes stale autoload/classmap more likely than usual on upgrade.bootstrap/app.php — that's a better use of time than trying to wedge a Symfony bundle into a Laravel app.How can I help you explore Laravel packages today?