Framework Cookbooks

Laravel Exception Handling: Renderable Exceptions and API Resources

How Laravel handles errors — the Handler class, renderable exceptions, API resource error responses, validation error formatting, and custom exception reporting.

The Exception Handler

Laravel's exception handling is centralized in app/Exceptions/Handler.php, which extends Illuminate\Foundation\Exceptions\Handler. All uncaught exceptions flow through two methods: report() and render().

<?php

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
    public function register(): void
    {
        $this->reportable(function (Throwable $e) {
            // Called for every exception — send to Sentry, Slack, etc.
            if ($this->shouldReport($e)) {
                \Sentry\captureException($e);
            }
        });

        $this->renderable(function (\App\Exceptions\ApiException $e, $request) {
            if ($request->expectsJson()) {
                return response()->json([
                    'error' => $e->getMessage(),
                    'code'  => $e->getErrorCode(),
                ], $e->getStatusCode());
            }
        });
    }
}

report() sends the exception to logs, Sentry, or any other monitoring system. It does not affect the HTTP response.

render() (or renderable()) turns the exception into an HTTP response. Laravel automatically detects whether the client expects JSON via the Accept header and the expectsJson() helper.

Exception Mapping

Laravel's base Handler maps common exceptions to status codes automatically:

ExceptionStatus Code
`ModelNotFoundException`404
`AuthorizationException`403
`AuthenticationException`401
`ValidationException`422
`ThrottleRequestsException`429
`MethodNotAllowedHttpException`405

Renderable Exceptions

Custom exceptions that implement render() are self-describing — they know how to turn themselves into HTTP responses:

<?php

namespace App\Exceptions;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class PaymentFailedException extends \RuntimeException
{
    public function __construct(
        private string $reason,
        private string $errorCode,
    ) {
        parent::__construct("Payment failed: {$reason}");
    }

    public function render(Request $request): JsonResponse|false
    {
        if ($request->expectsJson()) {
            return response()->json([
                'error'      => $this->reason,
                'error_code' => $this->errorCode,
            ], 402);
        }
        // Return false to let the default HTML handler take over
        return false;
    }

    public function report(): bool
    {
        // Return false to suppress reporting for expected failures
        return false;
    }
}

// In a controller:
throw new PaymentFailedException('Insufficient funds', 'INSUFFICIENT_FUNDS');

Returning false from render() falls back to the global handler — useful when the exception only needs special rendering for API requests.

Validation Errors

Laravel's FormRequest and Validator automatically throw ValidationException on failure, which returns 422 Unprocessable Entity with field-level errors:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CreateUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email'    => ['required', 'email', 'unique:users'],
            'name'     => ['required', 'string', 'min:2', 'max:100'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ];
    }

    public function messages(): array
    {
        return [
            'email.unique' => 'That email address is already registered.',
        ];
    }
}

For JSON requests, the 422 response looks like:

{
  "message": "The name field is required.",
  "errors": {
    "name": ["The name field is required."],
    "email": ["That email address is already registered."]
  }
}

To customize this structure globally, override invalidJson() in the Handler:

protected function invalidJson($request, ValidationException $exception)
{
    return response()->json([
        'error'  => 'Validation failed',
        'fields' => $exception->errors(),
    ], 422);
}

API Resources for Error Responses

For consistent API error shapes, create a dedicated error resource:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class ErrorResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'error'      => $this->resource['message'],
            'code'       => $this->resource['code'] ?? 'UNKNOWN_ERROR',
            'request_id' => $request->header('X-Request-ID'),
            $this->mergeWhen(
                app()->isLocal() && isset($this->resource['trace']),
                ['debug' => $this->resource['trace']],
            ),
        ];
    }
}

The mergeWhen() helper conditionally adds debug information only in local environments — never expose stack traces in production.

Production Setup

Maintenance Mode (503)

# Enable maintenance mode — returns 503 to all requests
php artisan down --secret="my-bypass-token" --status=503

# Bypass with the secret token in a cookie
# GET /bypass-maintenance-mode?secret=my-bypass-token

# Disable maintenance mode
php artisan up

Logging Channels

Configure config/logging.php to send errors to the appropriate destination:

'channels' => [
    'stack' => [
        'driver'   => 'stack',
        'channels' => ['daily', 'sentry'],
    ],
    'daily' => [
        'driver' => 'daily',
        'path'   => storage_path('logs/laravel.log'),
        'level'  => 'error',
        'days'   => 14,
    ],
],

Key Takeaways

  • Register custom renderers in Handler::register() using $this->renderable()
  • Implement render() on custom exception classes for self-contained HTTP responses
  • FormRequest validation automatically returns 422 with field-level errors for JSON clients
  • Use $request->expectsJson() (not wantsJson()) to detect API clients correctly
  • Never expose stack traces in production — gate debug info with app()->isLocal()

Related Protocols

Related Glossary Terms

More in Framework Cookbooks