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.

Syntax$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

ThrownStatusJSON body
ValidationException422message, plus errors keyed by field
AuthenticationException401message, the string Unauthenticated.
AuthorizationException403message
Model binding or findOrFail miss404message
Rate limiter429message, with Retry-After
Anything else500message, 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

Example · php
<?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.

Example · php
<?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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.