Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Errors, Logging & Debugging

Exception handling, custom error pages, the Log facade and debugging tools.

Things go wrong in every application: a record doesn't exist, input is invalid, a user isn't allowed to do something, a third-party API is down. Laravel turns each of these into an exception, then decides two things about it: whether to report it (write it to a log or error tracker) and how to render it (the HTML page or JSON the user receives). This lesson covers both, plus logging and the debugging tools you'll use daily.

throw / abort() controller, model, job… Exception handler withExceptions(...) report() log file, Slack, Sentry… skipped for 404, 422, 403… render() JSON for APIs, an error page for browsers, with a status code
Every uncaught exception goes through the same handler. Reporting and rendering are separate decisions.

Try it: the error gallery

Each button calls GET /api/demo/errors/{case} on this site. The controller throws one kind of exception on purpose, and Laravel's handler turns it into a response. Compare the status codes, headers and JSON bodies.

None of these requests are logged: they're expected errors, the kind a client causes, not bugs. A genuine bug (an uncaught TypeError, say) would render as a 500 with the message Server Error in production, and be written to the log with a full stack trace.

Where it's configured

In Laravel 11 and later, exception handling lives in bootstrap/app.php. This app adds one rule: anything under api/ is always rendered as JSON, even if the client forgot the Accept: application/json header.

bootstrap/app.php

->withExceptions(function (Exceptions $exceptions): void {
    // API routes always answer with JSON (422 errors, 404s), even without an Accept header
    $exceptions->shouldRenderJsonWhen(
        fn (Request $request) => $request->is('api/*') || $request->expectsJson()
    );
})->create();

Without that rule, a browser opening /api/demo/projects/nope would get Laravel's HTML 404 page instead of a JSON body.

Built-in exceptions and their status codes

You rarely need to write return response(..., 404) by hand. Throw (or let Laravel throw) the right exception and the handler picks the status:

Thrown by Exception Status Reported?
firstOrFail(), findOrFail(), route model binding ModelNotFoundException 404 No
abort(404), unknown route NotFoundHttpException 404 No
$this->authorize(), Gate::authorize() AuthorizationException 403 No
auth middleware with no user AuthenticationException 401 / redirect to login No
$request->validate(), Form Requests ValidationException 422 / redirect back No
throttle middleware ThrottleRequestsException 429 No
Expired CSRF token TokenMismatchException 419 No
Anything else e.g. RuntimeException, QueryException 500 Yes

The real controller behind the gallery triggers five of them:

app/Http/Controllers/Api/ErrorDemoController.php

/**
 * Throw one kind of exception on purpose so you can see how Laravel renders it.
 * Nothing here writes to the database or the log.
 */
public function __invoke(Request $request, string $case): JsonResponse
{
    match ($case) {
        // ModelNotFoundException → 404
        'not-found' => Project::where('slug', 'no-such-project')->firstOrFail(),

        // ValidationException → 422 with an errors bag
        'validation' => $request->validate([
            'email' => ['required', 'email'],
            'age' => ['required', 'integer', 'min:18'],
        ]),

        // HttpException via abort() → 403
        'forbidden' => abort(403, 'Only the project owner can delete tasks.'),

        // Our own exception with a render() method → 409
        'conflict' => $this->completeTask(Task::query()->completed()->firstOrFail()),

        // ThrottleRequestsException → 429, normally thrown by the throttle middleware
        'throttled' => throw new ThrottleRequestsException('Too Many Attempts.', null, ['Retry-After' => 60]),
    };

    return response()->json(['message' => 'No exception was thrown.']);
}

abort() and friends

abort() throws an HttpException with the status and message you give it. The conditional versions keep guard clauses on one line:

abort(403, 'Only the project owner can delete tasks.');
abort_if($task->isComplete(), 409, 'Task already completed.');
abort_unless($request->user()?->is($project->owner), 403);

Custom exceptions

When a business rule breaks, give it its own exception class. The code that detects the problem just throws it; the class knows how to render itself. This app has one for completing a task that's already done:

app/Exceptions/TaskAlreadyCompletedException.php

/**
 * A domain rule broken by the caller: completing a task that is already done.
 * It renders itself as a 409 and is expected, so it is not written to the log.
 */
class TaskAlreadyCompletedException extends RuntimeException implements ShouldntReport
{
    public function __construct(public readonly Task $task)
    {
        parent::__construct("Task #{$task->id} was already completed.");
    }

    /**
     * Render the exception into an HTTP response.
     */
    public function render(Request $request): JsonResponse
    {
        return response()->json([
            'message' => $this->getMessage(),
            'task_id' => $this->task->id,
            'completed_at' => $this->task->completed_at?->toIso8601String(),
        ], 409);
    }
}

And the code that throws it:

private function completeTask(Task $task): void
{
    if ($task->isComplete()) {
        throw new TaskAlreadyCompletedException($task);
    }
}

Three things to notice:

  1. render() on the exception class is picked up automatically: no handler registration needed. It returns a 409 Conflict with useful context (task_id, completed_at).
  2. ShouldntReport marks it as expected, so it never fills the log. Leave that interface off for exceptions that signal a bug you'd want to hear about.
  3. The constructor takes the model, so the message and the response both have the details. Exceptions are objects; give them data.

You can also register rendering and reporting centrally instead of on the class, which is handy for exceptions from packages you don't own:

// Illustrative — not in this app
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (PaymentDeclined $e, Request $request) {
        return response()->json(['message' => $e->getMessage()], 402);
    });

    $exceptions->report(function (PaymentDeclined $e) {
        Log::channel('payments')->warning('Payment declined', ['order' => $e->orderId]);
    })->stop(); // don't also write it to the default log

    $exceptions->dontReport([InvalidCoupon::class]);
    $exceptions->throttle(fn (Throwable $e) => Limit::perMinute(100)); // cap noisy errors
})

Debug mode and error pages

APP_DEBUG decides how much a user sees when something breaks:

  • APP_DEBUG=true (local only): JSON errors include exception, file, line and the full trace; browsers get the detailed error page with the code that failed.
  • APP_DEBUG=false (production, and this site): a 500 says only Server Error. Expected HTTP errors (403, 404, 409…) still show their message, because you wrote those messages for users.
Never run production with APP_DEBUG=true. Stack traces reveal file paths, SQL, and sometimes environment values such as passwords.

For browsers, customise the HTML error pages by creating a view named after the status code. Laravel uses it automatically:

{{-- resources/views/errors/404.blade.php — illustrative, not in this app --}}
@extends('layouts.app')

@section('content')
    <h1>Page not found</h1>
    <p>{{ $exception->getMessage() ?: 'That page has moved or never existed.' }}</p>
    <a href="{{ url('/') }}">Back to the home page</a>
@endsection

errors/4xx.blade.php and errors/5xx.blade.php act as fallbacks for any status in that range. Run php artisan vendor:publish --tag=laravel-errors to copy Laravel's own templates as a starting point.

Logging

The Log facade writes messages at the eight standard levels, from debug up to emergency. Always pass context as an array instead of building strings: log tools can then filter and search on each field.

This app's queued listener logs every completed task:

app/Listeners/LogTaskCompletion.php

public function handle(TaskCompleted $event): void
{
    $task = $event->task->loadMissing('project');

    Log::info('Task completed', [
        'task_id' => $task->id,
        'title' => $task->title,
        'project' => $task->project->slug,
    ]);
}

That produces a line like:

[2026-10-19 09:14:02] production.INFO: Task completed {"task_id":65,"title":"Design new homepage hero","project":"website-relaunch"}

Other everyday patterns:

Log::warning('Stripe webhook signature mismatch', ['ip' => $request->ip()]);
Log::error('Import failed', ['file' => $path, 'exception' => $e]);

// Add context to every log line for the rest of the request
Log::withContext(['request_id' => (string) Str::uuid()]);

// Send to a specific channel defined in config/logging.php
Log::channel('slack')->critical('Payments are failing');

// Report an exception you caught, without stopping the request
try {
    $rates = Http::get($url)->throw()->json();
} catch (RequestException $e) {
    report($e);
    $rates = $this->cachedRates();
}

Channels are configured in config/logging.php. The default stack channel fans out to the channels listed in LOG_STACK: single writes to storage/logs/laravel.log; daily rotates a file per day and deletes old ones; stderr (what the local Docker setup for this app uses) suits containers; slack and papertrail send to external services. Set LOG_LEVEL to hide debug noise in production.

Debugging tools

Tool Use it for
dump($value) / dd($value) Print a value (and stop, for dd). Also chainable: $query->dd(), collect(...)->dump().
php artisan pail Tail the application log live in the terminal, filtered by level or text (--filter="Task completed"). Installed in this app for development.
php artisan tinker Run code against the real app: try a query, dispatch a job, inspect config.
Laravel Telescope A dashboard of every request, query, job, mail and exception (illustrative: not installed here).
Laravel Debugbar A toolbar showing queries and timing on each page (illustrative: not installed here).
->toSql(), DB::listen() See the SQL a query produces; see the Query Builder lesson.
Leave no dd() behind: it ends the request with a 500 in production. A quick grep -rn "dd(" app/ before committing, or Pint plus code review, catches them.

Key takeaways

  • Every uncaught exception goes through one handler that reports it (log) and renders it (response). Configure both in bootstrap/app.php with withExceptions().
  • Use the built-in exceptions: findOrFail, abort, authorize and validate produce the right 404, 403 and 422 responses without extra code. They aren't logged.
  • Give business rules their own exception classes with a render() method. Mark expected ones ShouldntReport.
  • Keep APP_DEBUG=false in production. Customise browser error pages with resources/views/errors/{status}.blade.php.
  • Log with the Log facade and an array of context. Choose channels and levels in config/logging.php, and use report($e) for exceptions you catch and recover from.
  • dd, dump, pail and tinker cover most day-to-day debugging.