A Laravel custom exception can do far more than carry a message — it can shape its own HTTP response and enrich its own log lines. Most codebases never use that, and it shows. Here is a controller I have written, in some form, more times than I want to admit:
public function store(StoreOrderRequest $request): JsonResponse
{
try {
$order = $this->orders->place($request->validated());
} catch (PaymentGatewayException $e) {
Log::error('Payment failed', ['user' => $request->user()->id]);
return response()->json(['message' => 'Payment declined'], 502);
} catch (InventoryException $e) {
return response()->json(['message' => 'Out of stock'], 409);
} catch (RateLimitedException $e) {
return response()->json(['message' => 'Slow down'], 429);
}
return OrderResource::make($order)->response();
}
Twelve lines of plumbing around one line of work. And the same three catch blocks live in OrderController, SubscriptionController and the Artisan command that replays failed orders. A Laravel custom exception can carry all of that itself — the status code, the response shape, and the log context — leaving the controller with the single line that actually does something.
The Laravel custom exception that renders itself#
Laravel calls these renderable and reportable exceptions, and they need no wiring at all. If your exception class defines a render() method, the framework calls it and uses whatever it returns as the HTTP response. No registration, no configuration, no bootstrap/app.php entry:
<?php
namespace App\Exceptions\Payment;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentGatewayException extends Exception
{
public function __construct(
public readonly string $gateway,
public readonly ?string $declineCode = null,
string $message = 'The payment could not be processed.',
) {
parent::__construct($message);
}
public function render(Request $request): JsonResponse
{
return response()->json([
'message' => $this->getMessage(),
'decline_code' => $this->declineCode,
], 502);
}
}
The controller collapses to this:
public function store(StoreOrderRequest $request): JsonResponse
{
return OrderResource::make(
$this->orders->place($request->validated())
)->response();
}
Every caller gets the same 502 with the same body, forever, without anyone remembering to catch anything.
One ordering detail worth knowing: the handler checks the exception's own render() before it runs any $exceptions->render() closures from bootstrap/app.php, and before prepareException() converts things like ModelNotFoundException into HTTP exceptions. The method on the class always wins.
Content negotiation inside render()#
An exception thrown from a Blade-rendered page should not return JSON. Do the branching inside render() — it receives the request:
public function render(Request $request): JsonResponse|Response|bool
{
if ($request->expectsJson()) {
return response()->json([
'message' => $this->getMessage(),
'decline_code' => $this->declineCode,
], 502);
}
return response()->view('errors.payment', [
'exception' => $this,
], 502);
}
You can also opt out conditionally. The handler does a truthy check on the return value:
if (method_exists($e, 'render') && $response = $e->render($request)) {
So returning false (or null) hands control back to Laravel's default rendering. That is the escape hatch when your exception extends something already renderable and you only want to customise one case:
public function render(Request $request): Response|bool
{
if (! $this->declineCode) {
return false; // Let Laravel render the default response.
}
return response()->view('errors.payment', ['exception' => $this], 502);
}
Reporting: the return value that trips everyone up#
This is the bug I have watched three different teams hit, and it is entirely down to one line in Illuminate\Foundation\Exceptions\Handler::reportThrowable():
if (Reflector::isCallable($reportCallable = [$e, 'report']) &&
$this->container->call($reportCallable) !== false) {
return;
}
The check is !== false, not === true. So this innocent-looking method stops your exception being logged at all:
// Broken: this exception now never reaches your log or your error tracker.
public function report(): void
{
Metrics::increment('payments.declined');
}
A void method returns null. null !== false is true. The handler returns early and the default logging stack never runs. If you have ever wondered why a custom exception vanished from Sentry the day someone added a metrics counter to it, that is why.
The fix is one word:
/**
* Report the exception.
*
* Returning false hands the exception back to the default
* logging stack after our custom side effect has run.
*/
public function report(): bool
{
Metrics::increment('payments.declined');
return false;
}
Return true only when your report() really is the whole reporting story and you want the default log suppressed. And because the handler calls the method through the container, you can type-hint dependencies on it:
public function report(Metrics $metrics): bool
{
$metrics->increment('payments.declined', ['gateway' => $this->gateway]);
return false;
}
If your exceptions are disappearing rather than duplicating, it is almost always this, not your DSN. Worth checking before you go digging through a self-hosted Sentry install.
Attaching Laravel exception context to every log line#
context() is the highest-value method on this list and the least used. Return an array and Laravel merges it into the log context for that exception — which means Sentry, Flare and your JSON log driver all pick it up for free:
public function context(): array
{
return [
'gateway' => $this->gateway,
'decline_code' => $this->declineCode,
];
}
That turns a useless log line into a searchable one. If you are shipping logs somewhere queryable, these keys become filterable fields — the difference between "payments are failing" and "every failure in the last hour has decline_code: insufficient_funds on the stripe gateway". This pairs well with centralised logging in Grafana Loki, where structured context is what makes a query possible at all.
The exception only knows what it was constructed with, though. For request-scoped values — request id, tenant, authenticated actor — use Laravel's Context facade to attach data once in middleware and let it follow the request into queued jobs. Then add a global exception context callback in bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->context(fn () => [
'tenant_id' => Context::get('tenant_id'),
'request_id' => Context::get('request_id'),
]);
})
Know the merge order, because it decides who wins a key collision. Handler::buildExceptionContext() builds the array like this:
return array_merge(
$this->buildContextForException($e), // the exception's own context()
$this->context(), // ['userId' => Auth::id()]
['exception' => $e]
);
And inside exceptionContext(), the callbacks you registered with $exceptions->context() are merged over the exception's own array. So: global callbacks beat the exception's context(), userId beats both, and exception always wins last. Never use exception or userId as a key in your own context() — they will be silently overwritten.
Suppressing expected exceptions with ShouldntReport#
Some exceptions are control flow. A webhook replay that finds a duplicate is not an incident. You have three ways to stop reporting it, and they are not equivalent.
The one I reach for is the marker interface, because the intent lives with the class:
<?php
namespace App\Exceptions\Webhooks;
use Exception;
use Illuminate\Contracts\Debug\ShouldntReport;
class DuplicateWebhookException extends Exception implements ShouldntReport
{
//
}
The alternative is a list in bootstrap/app.php, which works but puts the decision a long way from the code that throws:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->dontReport([
DuplicateWebhookException::class,
]);
})
For anything conditional, dontReportWhen() takes a closure:
$exceptions->dontReportWhen(fn (Throwable $e) =>
$e instanceof SyncException && $e->reason() === 'subscription_expired'
);
Note that suppressing reporting does not suppress rendering. A ShouldntReport exception with a render() method still returns your custom response — it just never hits the log. That is usually exactly what you want.
Going the other way, Laravel silently ignores a dozen exception types by default, including HttpException, ModelNotFoundException, ValidationException, AuthenticationException and TokenMismatchException. If you actually want 404s in your error tracker for a period, un-ignore them:
$exceptions->stopIgnoring(NotFoundHttpException::class);
Throttling a noisy integration#
When a third-party API falls over at 3am, you do not want 40,000 identical events. throttle() takes a closure that returns a Limit, a Lottery, or null to fall through:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Lottery;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->throttle(function (Throwable $e) {
return match (true) {
// Cap a flapping upstream at 60 reports a minute, keyed per
// gateway so Stripe going down doesn't mask a PayPal outage.
$e instanceof PaymentGatewayException =>
Limit::perMinute(60)->by($e->gateway),
// Sample high-volume noise instead of capping it.
$e instanceof ApiMonitoringException => Lottery::odds(1, 1000),
default => Limit::none(),
};
});
})
By default the limit key is the exception class. ->by() overrides it, and the resulting key is hashed with xxh128 before it reaches the rate limiter, so a long message is fine. Limit::none() returns an Unlimited instance, which means "report everything" — it is the default when no callback matches.
One thing to be clear about: a throttled exception is dropped from reporting entirely. It is not downgraded to a lower log level, it just does not appear. That is a deliberate trade, and it is the right one at 3am, but do not reach for it to quieten an exception you have not diagnosed yet.
Handling exceptions you don't own#
render() and report() on the class only work when the class is yours. For vendor exceptions, register closures — Laravel resolves which one applies from the type-hint:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (ProviderTimeoutException $e, Request $request) {
if (! $request->expectsJson()) {
return; // Returning nothing falls back to default rendering.
}
return response()->json(['message' => 'Upstream timed out.'], 504);
});
$exceptions->report(function (ProviderTimeoutException $e) {
Metrics::increment('provider.timeout');
return false; // Same semantics: false means "also log normally".
});
$exceptions->level(PDOException::class, LogLevel::CRITICAL);
})
While you are in that file, set dontFlash(). Laravel flashes old input back to the session on validation failures, and the defaults only cover password, password_confirmation and current_password. Anything else you accept in a form ends up in the session, and from there into a session-store dump:
$exceptions->dontFlash([
'api_key',
'card_number',
'national_insurance_number',
]);
That is the same class of problem as secrets appearing in stack traces, which PHP solves with the SensitiveParameter attribute. Use both.
One error envelope for the whole API#
Once exceptions render themselves, the obvious next move is making every API error render the same shape. One abstract base per bounded context, one envelope, and clients have exactly one thing to parse:
<?php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
abstract class ApiException extends Exception
{
abstract public function errorCode(): string;
abstract public function status(): int;
/** @return array<string, mixed> */
public function meta(): array
{
return [];
}
public function render(Request $request): JsonResponse
{
return response()->json([
'error' => [
'code' => $this->errorCode(),
'message' => $this->getMessage(),
'meta' => (object) $this->meta(),
],
], $this->status());
}
/** @return array<string, mixed> */
public function context(): array
{
return ['error_code' => $this->errorCode()] + $this->meta();
}
}
Concrete children stay tiny, and static named constructors keep the throw site readable and the message in one place:
final class SyncException extends ApiException
{
private function __construct(
private readonly string $code,
private readonly int $status,
private readonly array $meta,
string $message,
) {
parent::__construct($message);
}
public static function rateLimited(int $retryAfter): static
{
return new static(
code: 'sync.rate_limited',
status: 429,
meta: ['retry_after' => $retryAfter],
message: "Sync is rate limited. Retry in {$retryAfter} seconds.",
);
}
public function errorCode(): string
{
return $this->code;
}
public function status(): int
{
return $this->status;
}
public function meta(): array
{
return $this->meta;
}
}
throw SyncException::rateLimited(30); reads like prose, returns a consistent 429, and logs error_code and retry_after without another line of code. The envelope sits naturally alongside the success shape you get from JSON:API resources in Laravel 13.
Gotchas and Edge Cases#
render() never runs in a queued job. There is no HTTP response to render, so only report() and context() fire. Retry and backoff behaviour is a separate mechanism — handle that with job middleware for rate limiting and backoff rather than trying to make the exception do it.
abort() bypasses your class entirely. abort(502) throws a Symfony HttpException, not your exception. If you want the envelope, throw the exception.
Throttling is checked inside shouldntReport(). It runs after the ShouldntReport interface check and the dontReport list, so an already-ignored exception never reaches the rate limiter and never consumes a slot.
dontReportDuplicates() is instance-scoped, not message-scoped. It uses a WeakMap keyed on the exception object, so it stops the same instance being reported twice by repeated report() helper calls. Two separate instances with identical messages are still two events. Use throttle() for that.
Context keys collide silently. Covered above, but it is worth repeating: exception and userId are reserved.
Testing a Laravel custom exception#
Three assertions cover almost everything a renderable exception and a reportable exception can do: the rendered response, the log context, and the absence of a report.
use App\Exceptions\Sync\SyncException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
it('renders the shared error envelope', function () {
Route::get('/_test/sync', fn () => throw SyncException::rateLimited(30));
$this->getJson('/_test/sync')
->assertStatus(429)
->assertJsonPath('error.code', 'sync.rate_limited')
->assertJsonPath('error.meta.retry_after', 30);
});
it('logs the exception context', function () {
Log::spy();
report(SyncException::rateLimited(30));
Log::shouldHaveReceived('error')->withArgs(
fn (string $message, array $context) =>
$context['error_code'] === 'sync.rate_limited'
&& $context['retry_after'] === 30
);
});
it('does not report exceptions marked ShouldntReport', function () {
Log::spy();
report(new DuplicateWebhookException('Already processed.'));
Log::shouldNotHaveReceived('error');
});
Registering a throwaway route inside the test is the cleanest way to exercise render() without dragging a real controller into it. If you find yourself writing the envelope assertion in twenty tests, wrap it in a custom Pest expectation.
Wrapping Up#
Start with one exception. Move a catch block's response into a render() method, add a context() returning the two ids you always wish were in the log, and delete the catch. Then do the next one. The bootstrap/app.php closures are for exceptions you don't own — reach for them second, not first.
Once the context is flowing, the next step is making it visible: production observability with Pulse, Nightwatch and OpenTelemetry is where those context() keys stop being log noise and start being dashboards.
FAQ#
How do I create a custom exception in Laravel?
Run php artisan make:exception PaymentGatewayException to generate a class in app/Exceptions, or write one by hand extending Exception. Laravel 11 and later have no app/Exceptions/Handler.php, so there is nothing to register — add render(), report() or context() methods directly to the class and the framework calls them automatically. Promote constructor properties for the data the exception carries so render() and context() can use it.
What is the difference between the render and report methods on a Laravel exception?
render(Request $request) turns the exception into an HTTP response and is only called during a web request. report() handles logging and error tracking, and runs everywhere including queued jobs and Artisan commands. They are independent: an exception can render a custom response and still be reported normally, or be silently ignored by the reporter while still rendering a friendly page.
Why is my custom Laravel exception not appearing in Sentry?
The most common cause is a report() method that returns void or null. Laravel's handler stops the default reporting stack unless report() returns exactly false, so any non-false return — including no return at all — suppresses the log and the Sentry event. Add return false; to the end of the method. The other causes are the ShouldntReport interface, an entry in dontReport(), and a throttle() rule that is dropping the exception.
How do I add extra context to a Laravel exception log?
Define a context(): array method on the exception and return the keys you want attached. Laravel merges that array into the log context for every report of that exception, which means Sentry, Flare and structured log drivers all receive it. For request-scoped data the exception cannot know about, register a global callback with $exceptions->context() in bootstrap/app.php and read from the Context facade.
How do I stop Laravel reporting a specific exception?
Implement Illuminate\Contracts\Debug\ShouldntReport on the exception class — that is the clearest option because the intent sits with the class. Alternatively, pass the class name to $exceptions->dontReport([...]) in bootstrap/app.php, or use $exceptions->dontReportWhen() with a closure for conditional suppression. None of these affect rendering, so the exception still produces its custom response.
How do I return JSON from an exception in a Laravel API?
Add a render() method that returns response()->json([...], $status). Laravel uses whatever you return as the response, so you control both the body shape and the status code. To handle web and API traffic from the same class, branch on $request->expectsJson() inside render() and return a Blade view for the browser case.
How do I throttle duplicate exception reports in Laravel?
Use $exceptions->throttle() in bootstrap/app.php and return an Illuminate\Cache\RateLimiting\Limit for a hard cap or an Illuminate\Support\Lottery for random sampling. Limit::perMinute(300) caps reports per minute keyed on the exception class; call ->by($someValue) to key on something narrower such as a gateway name. Returning Limit::none() reports everything, which is the default when no rule matches.