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:
| Exception | Status 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 FormRequestvalidation automatically returns 422 with field-level errors for JSON clients- Use
$request->expectsJson()(notwantsJson()) to detect API clients correctly - Never expose stack traces in production — gate debug info with
app()->isLocal()