Error Handling and Logging

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Every example runs in the Bookshelf application used across this guide. Laravel catches every exception that escapes a controller, does two separate things with it, reports it (writes it to the log or sends it to an error tracker) and renders it (turns it into an HTTP response), and lets each step be configured. How PHP itself reports errors, and what display_errors does, is on Errors and Exceptions.

Configuring exceptions in bootstrap/app.php

There is no app/Exceptions/Handler.php in a new application. The withExceptions() closure configures both steps:

<?php
// bootstrap/app.php (the withExceptions() part)
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;

return Application::configure(basePath: dirname(__DIR__))
    // ... withRouting() and withMiddleware() unchanged ...
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
        $exceptions->context(fn () => ['app' => 'bookshelf']);                     // on every report
        $exceptions->dontReport([\Illuminate\Contracts\Cache\LockTimeoutException::class]);
        $exceptions->level(\PDOException::class, \Psr\Log\LogLevel::CRITICAL);
        $exceptions->throttle(fn (Throwable $e) => $e instanceof \App\Exceptions\CatalogueServiceDown
            ? \Illuminate\Cache\RateLimiting\Limit::perMinute(2)
            : null);
        $exceptions->report(function (\App\Exceptions\CatalogueServiceDown $e) {
            \Illuminate\Support\Facades\Log::channel('single')->warning('catalogue down', ['msg' => $e->getMessage()]);
            return false;                                                          // stop default logging
        });
        $exceptions->render(function (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e, Request $request) {
            if ($request->is('api/*')) {
                return response()->json(['type' => 'about:blank', 'title' => 'Not Found', 'status' => 404], 404,
                    ['Content-Type' => 'application/problem+json']);
            }
        });
        $exceptions->dontReportDuplicates();
    })->create();
Method Effect

report(fn (Type $e) => ...)

Extra reporting for one exception type (the type comes from the closure’s parameter). Return false to skip the default log entry.

dontReport([...]), stopIgnoring([...])

Never report these types; or report types Laravel ignores by default (404s, CSRF and origin failures).

level(Type::class, LogLevel::CRITICAL)

The log level used for a type (default error).

throttle(fn ($e) => Lottery::odds(1, 1000) or Limit::perMinute(n))

Sample or rate-limit reports, so an outage does not flood the log or the error tracker.

context(fn () => [...]), dontReportDuplicates()

Data added to every report; report one exception instance only once even if report() is called again.

render(fn (Type $e, Request $r) => ...), shouldRenderJsonWhen(), respond()

A custom response for a type (return null to fall through); when to use JSON; post-process every error response.

The throttle was checked by requesting /e/down, which throws CatalogueServiceDown, four times within a minute: all four answered 500, and the log received two catalogue down warnings. A Limit counts per exception class by default, in the cache store.

Reportable and renderable exceptions

An exception class can carry its own context(), report() and render() methods, which keeps the knowledge in the domain code instead of the global configuration:

<?php
// app/Exceptions/BookUnavailableException.php
namespace App\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

/** Reportable and renderable: the exception knows how to log and display itself. */
class BookUnavailableException extends Exception
{
    public function __construct(public readonly string $isbn)
    {
        parent::__construct("Book {$isbn} is out on loan.");
    }

    public function context(): array                       // added to the log entry
    {
        return ['isbn' => $this->isbn];
    }

    public function render(Request $request): Response|JsonResponse
    {
        if ($request->expectsJson()) {
            return response()->json(['message' => $this->getMessage(), 'isbn' => $this->isbn], 409);
        }

        return response()->view('errors.book-unavailable', ['isbn' => $this->isbn], 409);
    }
}
$ curl -s -w ' %{http_code}\n' localhost:8000/e/loaned/978-0-446-67697-7
<h1>On loan</h1>
<p>978-0-446-67697-7 is out on loan. Try again next week.</p>
 409
$ curl -s -H 'Accept: application/json' -w ' %{http_code}\n' localhost:8000/e/loaned/978-0-446-67697-7
{"message":"Book 978-0-446-67697-7 is out on loan.","isbn":"978-0-446-67697-7"} 409

# storage/logs/laravel.log
[2026-10-05 20:13:36] production.ERROR: Book 978-0-446-67697-7 is out on loan. {"isbn":"978-0-446-67697-7","app":"bookshelf","exception":"[object] (App\\Exceptions\\BookUnavailableException(code: 0): …

The first version of this class returned false from render() for JSON requests, to "fall back to Laravel’s JSON rendering". The result was {"message":"Server Error"} with status 500: Laravel’s default rendering only knows the status of HTTP exceptions, so any other exception becomes a 500. Render the JSON yourself, or extend Symfony\Component\HttpKernel\Exception\HttpException to carry a status. The log entry also shows that the exception’s context() and the global context() were merged.

To log an exception and carry on, call the report() helper instead of rethrowing:

<?php
// routes/catalogue.php
use Illuminate\Support\Facades\Route;

Route::get('/e/report', function () {
    try {
        throw new RuntimeException('Cover service slow');
    } catch (RuntimeException $e) {
        report($e);                                          // log it, carry on
    }
    return 'served without a cover';
});

The page was served (served without a cover) and the log received production.ERROR: Cover service slow. rescue(fn () => ..., $default) is the one-line form: run the closure, report any exception and return the default.

HTTP exceptions and error pages

abort(403, 'Members only.') throws an HttpException with that status; abort_if($condition, ...) and abort_unless() are the conditional forms, and abort(404) is what route model binding does for a missing model. For an HTTP exception the message is shown to the user, so it must not contain internals:

$ curl -s -H 'Accept: application/json' -w ' %{http_code}\n' localhost:8000/e/abort
{"message":"Members only."} 403
$ curl -s -H 'Accept: application/json' -w ' %{http_code}\n' localhost:8000/e/abort-if
{"message":"An ISBN is required."} 422
$ curl -s -w ' %{http_code}\n' localhost:8000/nowhere
<h1>Not on the shelf</h1>
<p>There is no page at this address.</p>
 404
$ curl -s -D - localhost:8000/api/missing
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"about:blank","title":"Not Found","status":404}

The 404 page is resources/views/errors/404.blade.php, which receives the exception as $exception; errors/4xx.blade.php and errors/5xx.blade.php are fallbacks for any status in the range, and php artisan vendor:publish --tag=laravel-errors copies the default templates for editing. The render() callback for NotFoundHttpException made the API answer with an RFC 9457 problem document, the format described on HTTP Clients, APIs and Email.

Debug and production output

APP_DEBUG decides how much a 500 reveals. With APP_DEBUG=true (the local .env), the JSON for /e/boom (a RuntimeException('Shelf collapsed')) contains the message, the exception class, the file, the line and the full stack trace, and the HTML is Laravel’s debug page with the source and the request. With APP_ENV=production APP_DEBUG=false:

$ curl -s -H 'Accept: application/json' -w ' %{http_code}\n' localhost:8000/e/boom
{
    "message": "Server Error"
} 500
$ curl -s localhost:8000/e/boom | grep -o '<title>[^<]*</title>'
<title>Server Error</title>

A production server with APP_DEBUG=true publishes paths, queries, environment details and sometimes credentials in every error, which is one of the most common Laravel misconfigurations found by scanners. The details belong in the log, where the same request wrote the full exception and stack trace.

Logging channels and stacks

config/logging.php defines channels, each a Monolog logger with a driver. The default is the stack channel, which forwards to the channels listed in LOG_STACK:

# .env of a new application
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
Driver Writes to

single

One file, storage/logs/laravel.log (Monolog StreamHandler).

daily, monthly

One file per day or month, keeping LOG_DAILY_DAYS (14) files; monthly is new in Laravel 13.

stderr, errorlog, syslog

Standard error, PHP’s error_log(), or syslog: the choices for containers and platforms that collect output.

slack, papertrail

A Slack webhook or Papertrail.

monolog

Any Monolog handler, with its own formatter and processors.

custom

A factory class that builds the logger.

stack

Several channels at once, each with its own minimum level.

In containers, LOG_CHANNEL=stderr (or LOG_STACK=stderr) sends logs to the platform instead of a file inside an ephemeral filesystem. LOG_STACK=daily,slack with 'level' => 'critical' on the Slack channel writes everything to files and only critical entries to Slack. The levels are the eight of RFC 5424, from debug to emergency.

Writing logs and context

The Log facade writes to the default channel; an array argument adds context. The Context facade (Laravel 11) holds data for the whole request: it is appended to every log entry, and it is serialized into queued jobs and restored in the worker. Hidden context propagates but is never logged:

<?php
// routes/catalogue.php
use App\Jobs\ImportCover;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;

Route::get('/log/demo', function () {
    Context::add('trace_id', (string) Str::uuid7());
    Context::addHidden('member_id', 42);                      // propagated, never logged

    Log::info('loan created', ['isbn' => '978-0-446-67697-7']);
    Log::withContext(['shelf' => 'A3'])->warning('shelf nearly full');
    Log::channel('daily')->debug('to the daily file only');
    Log::build(['driver' => 'single', 'path' => storage_path('logs/audit.log')])->notice('audit: loan created');

    ImportCover::dispatch('978-0-446-67697-7');

    return 'logged';
});
<?php
// app/Jobs/ImportCover.php
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Log;

class ImportCover implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $isbn) {}

    public function handle(): void
    {
        // Context captured when the job was dispatched is restored here, in the worker
        Log::info('cover imported', ['isbn' => $this->isbn, 'hidden_seen' => Context::getHidden('member_id')]);
    }
}
$ curl -s localhost:8000/log/demo && php artisan queue:work --once --quiet
logged

# storage/logs/laravel.log
[2026-10-05 20:13:50] local.INFO: loan created {"isbn":"978-0-446-67697-7"} {"trace_id":"01a10db3-8139-7157-8b3f-a9a24226c604"}
[2026-10-05 20:13:50] local.WARNING: shelf nearly full {"shelf":"A3"} {"trace_id":"01a10db3-8139-7157-8b3f-a9a24226c604"}
[2026-10-05 20:13:50] local.INFO: cover imported {"isbn":"978-0-446-67697-7","hidden_seen":42} {"trace_id":"01a10db3-8139-7157-8b3f-a9a24226c604"}

# storage/logs/laravel-2026-10-05.log (daily channel)
[2026-10-05 20:13:50] local.DEBUG: to the daily file only  {"trace_id":"01a10db3-8139-7157-8b3f-a9a24226c604"}

# storage/logs/audit.log (on-demand channel)
[2026-10-05 20:13:50] local.NOTICE: audit: loan created  {"trace_id":"01a10db3-8139-7157-8b3f-a9a24226c604"}

The second JSON object on each line is the Context, printed after the message’s own context: the trace_id reached all three files and the queued job, which ran in a separate worker process, so one ID ties a request to the jobs it started. The hidden member_id was available in the job but appears in no log line. Log::withContext() attaches data to the following entries of that channel only; Log::shareContext() shares it with every channel. The trace_id uses Str::uuid7(), which sorts by time.

Tailing logs with Pail

php artisan pail (the laravel/pail package, a dev dependency of new applications and one of the processes php artisan dev, called by composer run dev, starts) streams log entries as they are written, from any driver, with request details. --level, --filter, --message and --user narrow the stream:

$ php artisan pail --level=warning
   INFO  Tailing application logs.                                     Press Ctrl+C to exit
┌ 20:14:00 WARNING ──────────────────────────────────────────────────────────────────────────┐
│ shelf nearly full                                                                          │
└────────────── GET: /log/demo • Auth ID: guest • shelf: A3 • trace_id: 01a10db3-a758-7108-… ┘

--level matches one level exactly: the ERROR from /e/boom requested in the same window was not shown. Pail needs the pcntl extension.

Where older books and tutorials are outdated

  • app/Exceptions/Handler.php with report(), render() and the $dontReport property (Stauffer ch. 10) is replaced by withExceptions() in bootstrap/app.php (Laravel 11); existing handlers keep working after an upgrade.

  • Ignition as the debug page (facade/ignition in Laravel 6 to 8, spatie/laravel-ignition in 9 and 10) is no longer in new applications, which show Laravel’s own debug page.

  • tail -f storage/logs/laravel.log still works for file channels, but Pail (Laravel 10) works with every driver and filters by level, user and message.

  • A request ID added by custom middleware and passed to jobs by hand is covered by Context (Laravel 11), which propagates to queued jobs automatically.

  • abort() messages copied from exceptions leak internals; HTTP exception messages are shown to users, other exceptions' messages only when APP_DEBUG is on.