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();
Source: Laravel — Handling Exceptions
| Method | Effect |
|---|---|
|
Extra reporting for one exception type (the type comes from the closure’s parameter). Return |
|
Never report these types; or report types Laravel ignores by default (404s, CSRF and origin failures). |
|
The log level used for a type (default |
|
Sample or rate-limit reports, so an outage does not flood the log or the error tracker. |
|
Data added to every report; report one exception instance only once even if |
|
A custom response for a type (return |
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';
});
Source: Laravel — The report Helper
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}
Source: Laravel — HTTP Exceptions and Custom HTTP Error Pages
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
Source: Laravel — Logging Configuration
| Driver | Writes to |
|---|---|
|
One file, |
|
One file per day or month, keeping |
|
Standard error, PHP’s |
|
A Slack webhook or Papertrail. |
|
Any Monolog handler, with its own formatter and processors. |
|
A factory class that builds the logger. |
|
Several channels at once, each with its own minimum |
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"}
Source: Laravel — Writing Log Messages and Laravel — Context
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.phpwithreport(),render()and the$dontReportproperty (Stauffer ch. 10) is replaced bywithExceptions()inbootstrap/app.php(Laravel 11); existing handlers keep working after an upgrade. -
Ignition as the debug page (
facade/ignitionin Laravel 6 to 8,spatie/laravel-ignitionin 9 and 10) is no longer in new applications, which show Laravel’s own debug page. -
tail -f storage/logs/laravel.logstill 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 whenAPP_DEBUGis on.