Errors and Status Codes
The exception handler already maps thrown exceptions to status codes and JSON bodies; bootstrap/app.php is where you change the shape.
$exceptions->render(function (Throwable $e, Request $request) { return response()->json([...], 422); });Laravel 11 removed app/Exceptions/Handler.php and moved its configuration into the withExceptions() closure in bootstrap/app.php; Laravel 13 is the same. The object that closure receives is an Illuminate\Foundation\Configuration\Exceptions instance.
What you get before configuring anything
| Thrown | Status | JSON body |
|---|---|---|
ValidationException | 422 | message, plus errors keyed by field |
AuthenticationException | 401 | message, the string Unauthenticated. |
AuthorizationException | 403 | message |
Model binding or findOrFail miss | 404 | message |
| Rate limiter | 429 | message, with Retry-After |
| Anything else | 500 | message, the string Server Error |
HTML or JSON
The handler picks between the error page and a JSON body from the request's Accept header, not from which route file matched. shouldRenderJsonWhen() overrides that decision.
Debug mode decides what leaks
With APP_DEBUG=true a 500 body also carries exception, file, line and the whole trace. With it false the message of a non-HTTP exception is replaced by Server Error. That substitution is the only thing between a client and your file paths, package versions and query fragments.
Your own shape
render() rewrites the response for one exception type; respond() sees every response the handler produces. Keep one envelope so a client parses failures in one branch.
Example
<?php
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->throttleApi();
})
->withExceptions(function (Exceptions $exceptions): void {
// Laravel's default 404 message names the model class it could not
// find. Clients do not need that.
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json(['message' => 'Resource not found.'], 404);
}
// Returning nothing falls through to the default rendering.
});
// The handler rewrites an Illuminate\Auth\Access\AuthorizationException
// into Symfony's AccessDeniedHttpException before any render callback
// runs, so that is the type to match on. A callback type-hinted on
// AuthorizationException is never reached.
$exceptions->render(function (AccessDeniedHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'This action is not allowed for your token.',
], 403);
}
});
// Runs for every response the handler builds. Add the machine-readable
// code clients branch on, without touching any status code.
$exceptions->respond(function (Response $response, Throwable $e, Request $request) {
if (! $request->is('api/*') || ! $response->isClientError()) {
return $response;
}
$payload = json_decode($response->getContent(), true) ?: [];
$payload['code'] = class_basename($e);
return response()->json($payload, $response->getStatusCode());
});
// Expected, noisy, and not worth a log line.
$exceptions->dontReport([
\App\Exceptions\PaymentDeclinedException::class,
]);
})->create();
When to use it
- Give every API failure the same body so the client has one error path.
- Stop a 404 from naming the Eloquent model class that was not found.
- Return 403 with a readable message when a token lacks an ability.
- Keep an expected domain exception out of the logs without hiding it from the client.
More examples
The 422 body a form request produces
Hand this shape to whoever writes the client: message is for display, errors is for field-level handling. Both come free with $request->validate() and with any form request, as long as the caller sends Accept: application/json.
<?php
// A form request throws ValidationException. On an API route there is no
// session to flash errors into and nothing to redirect back to, so the
// handler returns this instead.
// POST /api/posts with {"title": "", "body": "x"}
// 422 Unprocessable Entity
//
// {
// "message": "The title field is required. (and 1 more error)",
// "errors": {
// "title": ["The title field is required."],
// "body": ["The body field must be at least 10 characters."]
// }
// }
// Nested and array fields are flattened into dot keys, which is what a
// client needs in order to put a message next to an input:
//
// {
// "message": "The authors.0.email field must be a valid email address.",
// "errors": {
// "authors.0.email": ["The authors.0.email field must be a valid email address."]
// }
// }
An exception that renders itself
Put the response next to the exception when only that exception needs it; bootstrap/app.php stays for cross-cutting rules. The handler looks for render() and report() methods on the exception itself, and returning false from render() asks for the default behaviour.
<?php
// app/Exceptions/PaymentDeclinedException.php
// Generated by: php artisan make:exception PaymentDeclinedException
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentDeclinedException extends Exception
{
public function __construct(
private readonly string $reason,
) {
parent::__construct("Payment declined: {$this->reason}");
}
/**
* The handler calls this automatically when the exception reaches it.
*/
public function render(Request $request): JsonResponse|bool
{
if (! $request->expectsJson()) {
return false; // Fall back to the default HTML rendering.
}
return response()->json([
'message' => 'The payment was declined.',
'code' => 'payment_declined',
'reason' => $this->reason,
], 402);
}
/**
* Context added to this exception's log entry.
*
* @return array<string, mixed>
*/
public function context(): array
{
return ['reason' => $this->reason];
}
}
The same 500, with and without debug
Check this before any launch. The substitution happens in the handler based on config('app.debug'), so a .env left at true in production turns every unhandled error into a disclosure of your directory layout and dependency versions.
<?php
// APP_DEBUG=true — a division by zero in a controller:
//
// {
// "message": "Division by zero",
// "exception": "DivisionByZeroError",
// "file": "/var/www/app/Http/Controllers/Api/ReportController.php",
// "line": 42,
// "trace": [ ... every frame, with file paths and line numbers ... ]
// }
// APP_DEBUG=false — the same request:
//
// {
// "message": "Server Error"
// }
// An HTTP exception keeps its message either way, because you wrote it:
abort(503, 'Reports are rebuilding, try again in a minute.');
//
// { "message": "Reports are rebuilding, try again in a minute." }
Discussion