phphd/exception-handler
PHPhD Exception Handler offers a structured way to manage exceptions in Laravel/PHP applications, with configurable handling and cleaner error responses for more predictable debugging and user-facing failures.
Install and publish in two commands:
composer require phphd/exception-handler
php artisan vendor:publish --tag="exception-handler-config"
Package discovery registers the service provider. On older skeletons or with discovery disabled, add it to bootstrap/providers.php.
Where to look first: the published config. It's the handler registry and the resolution order — the file you'll open every time something "doesn't fire." Second stop is the handler contract (the handle() signature and required return type); you're implementing an interface, not extending a base class.
Scaffold your first handler:
php artisan make:exception-handler PaymentFailedExceptionHandler
First use case: pick your noisiest if ($e instanceof ...) chain — auth, domain/payment failures, or third-party API errors. Those benefit most from one-class-per-exception. Implement one, register it, delete the corresponding branch, verify with a feature test that asserts status + JSON shape.
final class PaymentFailedExceptionHandler
{
public function handle(PaymentFailedException $e): Response
{
report($e); // skip for expected control flow
return response()->json([
'message' => 'Payment could not be processed.',
'code' => 'payment_failed',
], 402);
}
}
Migrate one exception per PR. Don't rewrite your whole error path in a single branch — you lose the ability to bisect regressions.
Day-to-day, the package behaves like a routing table for failures: exception in → handler matched → response or fall-through. Work in these patterns.
Response or null. null means "fall through to the next handler, eventually the framework default." Use it for log-and-continue; use a real response for terminal cases.Throwable last. When your ValidationException or AuthenticationException is being swallowed, it's an ordering problem, not a handler bug — reorder, don't debug the handler.handle() on $request->expectsJson(), or register two handlers and let the first that can answer win. Branching is simpler; splitting scales better once the JSON envelope is standardized.ApiErrorResponse helper — status, code string, message, request/trace ID. Consistency is the whole value proposition.report($e) explicitly for unexpected failures; skip it for expected control flow (rate limits, 404s, domain validation) so Sentry/Bugsnag stays signal-rich.config:clear → re-run.bootstrap/app.php ->withExceptions(), precedence becomes non-obvious and debugging turns into archaeology. One owner per exception — decide and document it.return explicitly.php artisan config:clear (and config:cache again).$e->getMessage() and $e->getTraceAsString() must not reach production clients. Log them; return a stable code + message.app()->runningInConsole() (or check request type) before returning an HTTP-shaped response.withoutExceptionHandling() in tests is the fastest way to prove a handler is masking a genuine bug — use it liberally while debugging.dd($e)s. Confirm it fires, delete it, retest. This isolates "never matched" from "matched but response was wrong" in one step.^1.1. Between minors, the handler contract is the piece most likely to shift; treat it as critical-path code (reviewed, pinned, documented rollback).How can I help you explore Laravel packages today?