graham-campbell/exceptions
Graham Campbell's Exceptions package gives Laravel apps a cleaner, more powerful exception handling setup, with custom handlers, reporting, throttling, and rendering so errors are easier to manage and debug.
composer require graham-campbell/exceptions:^17.2
php artisan vendor:publish --provider="GrahamCampbell\Exceptions\ExceptionsServiceProvider"
Require ^17.2 if you're on Laravel 11 / PHP 8.4 — that's the release that officially adds those constraints. Anything lower will refuse to install or fight you on platform requirements.config/exceptions.php top to bottom before writing any code. The array keys — filters, transformers, handlers, displayers, levels — are the mental model for everything the package does. You're configuring an ordered pipeline, not just a status-code map. If you understand that pipeline, most "why is my response shaped like this?" questions answer themselves.Illuminate\Contracts\Debug\ExceptionHandler. Laravel's default rendering/reporting no longer runs. Before installing, confirm which handler is bound (app(\Illuminate\Contracts\Debug\ExceptionHandler::class)) so you have a clear before/after. Half of all confusion on this package comes from people assuming they're adding a listener instead of replacing the whole pipeline.bootstrap/app.php in your ->withExceptions() block — the withExceptions closure remains the framework-level configuration surface, so you'll be adding to it rather than fighting it.app/Exceptions/Handler.php and swap the parent class.curl -H "Accept: application/json" (JSON). Confirm four distinct response shapes, and confirm they're coming from the package (add a marker key in config temporarily if you need to verify). This is your baseline — anything that diverges later is a config regression.{"message": "..."} blob, you define stable error codes and payload shapes per exception class and stop scattering try/catch + abort() + response()->json() blocks through controllers. That's the day-one ROI and the reason most teams install this.handlers array is ordered — the first match wins. Add entries from most specific to least specific (specific ValidationException, then ModelNotFoundException, then a catch-all Throwable fallback). Never rely on insertion order for anything you didn't explicitly reason about.HandlerInterface that produces an ExceptionInfo object from the exception. Keep these pure — they should only inspect the exception and return metadata. If you need the current user, a request ID, or a correlation ID, resolve it at throw time (via a middleware-bound context or a request-bound singleton) and read it back from the handler, not by hitting the DB from the handler.PaymentDeclinedException, InventoryException, etc. into a uniform shape so the handlers and displayers only ever see one type. This is where you add fields like code, type, retryable — the bits that your API contract cares about.code, human-readable message, trace_id if you have one. Keep the JSON shape stable and version it in docs; clients will hardcode against it.levels for per-exception log severity. A ValidationException should be info or debug; a ModelNotFoundException is notice; a QueryException is error/critical. Getting this right is one of the biggest signal-to-noise wins — most teams end up here because they're drowning in 4xx noise in their logs.HttpResponseException, or anything already shaped by a third-party package that owns its own rendering).report() / reporting path through a single adapter class rather than letting each handler reach into a vendor SDK. The levels config plus one reporting entry point is the cleanest layering.render() override on the native handler as the rollback. You want to be able to flip back in one deploy, not one sprint.handlers array. If your catch-all Throwable handler is above a specific ValidationException handler, every validation error will render as a 500. This bite is frequent and non-obvious — keep the catch-all at the bottom and comment it.php artisan config:cache will expose any closure-in-config. Config values must be serializable. If you're tempted to inline a closure for handler selection, don't — use a class reference instead. Run config:cache then hit your smoke-test routes as a required CI step; this catches cached-config divergence that only shows up in staging.report() is still firing somewhere (a Handler override that calls both, or a listener that reports alongside the package's reporting), you'll get two Sentry events per error and a doubled error-rate dashboard. Verify with a single throw + a vendor sandbox before shipping.debug is not just derived from APP_DEBUG but that the displayer explicitly strips stack traces and internal fields when APP_ENV=production. Test by flipping APP_DEBUG=false locally and hitting a 500 — if you see internal hostnames, you have a bug.withExceptions() closure and the package's config are two surfaces that both touch the same behavior. The package wins for rendering/reporting once bound, but withExceptions() still owns shouldRenderJsonWhen, dontReport, and the renderable() closure. Document which surface owns what in a comment in bootstrap/app.php — reviewers and future-you need this. If you find both configured for the same exception, the package handler is authoritative; remove the framework override to avoid confusion.failed_jobs go through the same reporter. If you're mapping exceptions to log levels, consider whether a job exception should have the same level as a request exception — often not. You may need a filter that inspects app()->runningInConsole() and adjusts.composer.lock, keep the customization surface thin, and document a one-sprint exit path (revert to Laravel's native handler + a small render() override) before you go past v1.How can I help you explore Laravel packages today?