Building Web Apps with Laravel Module 11 – Inside the framework
Errors, logging and context
Configure how Laravel 13 reports and renders errors, keep exception details off visitors' pages, and tie every log line to its request with context.
What you will learn
- Configure exception reporting and rendering in bootstrap/app.php
- Show generic error pages to visitors while the details stay in the logs
- Log to the right channel and level for each environment, with secrets masked
- Trace one request across every log line with the Context facade
- Test the error pages and the log lines so that a leak fails the build
Before you start
What you’ll learn · Before you start
What you will learn
- Configure exception reporting and rendering in bootstrap/app.php
- Show generic error pages to visitors while the details stay in the logs
- Log to the right channel and level for each environment, with secrets masked
- Trace one request across every log line with the Context facade
- Test the error pages and the log lines so that a leak fails the build
Before you start
On this page
Pathshala’s fee sync failed one evening, and the 500 page a parent saw showed the database password, while the log could not say which of that hour’s requests had failed. Laravel handles errors in two steps: it reports each exception to the logs and renders a response for the visitor. Its logging writes lines to channels, and context adds fields such as a request id to every line of a request.
You will run one failing route with debug mode on and off, and write log lines that carry their request id. Later parts control how your own exceptions are reported and rendered, and keep logs searchable with no secrets in them, into queued jobs too.
Laravel reports errors, then renders a response
Every exception that leaves your code reaches Laravel’s exception handler: The part of Laravel that receives every exception that leaves your code. It reports the exception, usually to the log, then renders the response the visitor gets. Its rules live in the withExceptions callback of bootstrap/app.php., which deals with it in two separate steps. exception reporting: Recording an exception for the team, by default as a log line with its message and stack trace. It happens the same way whether debug mode is on or off. records the exception, by default as a log line with its message and its stack trace. exception rendering: Turning an exception into the response a visitor receives: an HTML page, or JSON for a client that asks for it. Debug mode decides how much of the exception the response shows. turns it into the response the visitor receives: an HTML page, or JSON for a client that asks for one. Both steps are set up in the withExceptions() callback of bootstrap/app.php, and a new project has one rule there, which picks JSON for paths under api/ and for requests that ask for JSON:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create(); Follow the exception that Pathshala’s fee sync route throws:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- An exception leaves the route. The fee sync route throws a RuntimeException. Nothing in the route catches it, so it travels up through the middleware towards the HTTP kernel.
- The kernel catches it. The kernel never lets an exception reach PHP's own error output. It hands the exception to Laravel's exception handler, which works in two separate steps.
- Step one reports. Reporting writes the exception to the log, with its message and its stack trace. It happens the same way whether debug mode is on or off, which is why the log held the message in both runs.
- Step two renders. Rendering builds what the visitor receives. The skeleton's one rule in bootstrap/app.php picks JSON for an api path or a client that asks for it, and an HTML page otherwise; debug mode decides how much of the exception either one shows.
- The response goes out. The visitor gets status 500 with the generic page or the body message Server Error. The details stay in the log, where only the team can read them.
The same callback holds the rules that handle errors you expect. With dontReport(), a kind of exception is rendered but never logged. With throttle(), only some reports of a noisy exception are kept. An exception class can also carry report() and render() methods of its own, which the handler calls instead of its defaults.
Debug mode decides how much of the errors a visitor sees
Debug mode follows APP_DEBUG, which a new project’s .env sets to true. The session sends one failing request with debug on and off, as a browser and as a JSON client, and the route’s message holds an invented password:
routes/web.php · debug/routes/web.php
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
// A sync job written in a hurry: its error message includes the database password (an invented one).
// The lesson uses it to show where such a message ends up; never put a secret into an exception message.
Route::get('/fees/sync', function () {
throw new RuntimeException('Could not reach the fee database: DB password is hunter2');
}); debug/tests/Feature/ErrorPagesTest.php
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Route;
use RuntimeException;
use Tests\TestCase;
// Run it in the pipeline with the production settings: it fails the build whenever a 500 shows exception details.
class ErrorPagesTest extends TestCase
{
public function test_a_server_error_shows_no_exception_details(): void
{
Route::get('/boom', fn () => throw new RuntimeException('Could not reach the fee database: DB password is hunter2'));
$page = $this->get('/boom')->assertStatus(500)->getContent();
$this->assertFalse(str_contains($page, 'hunter2'), 'The 500 page shows the exception message.');
$json = $this->getJson('/boom')->assertStatus(500);
$this->assertSame('Server Error', $json->json('message'), 'The JSON error shows the exception message.');
}
} """Debug mode and what a visitor sees: the same failing route with APP_DEBUG=true and APP_DEBUG=false, for a browser
and for a JSON client, then a test that fails while debug is on and passes once it is off. The password in the
message is invented. Files: the folder debug/ next to this file."""
from session import run
run(
"""
export COLLISION_IGNORE_DURATION=true
export APP_DEBUG=true
php debug_probe.php
!php artisan test --filter=ErrorPagesTest
export APP_DEBUG=false
php debug_probe.php
php artisan test --filter=ErrorPagesTest
""",
app=True,
overlay=("debug",),
) Output
$ export COLLISION_IGNORE_DURATION=true
$ export APP_DEBUG=true
$ php debug_probe.php
APP_DEBUG=true
browser: status 500, page shows the password: yes, page title: Laravel
JSON client: status 500, body keys: message, exception, file, line, trace
message: "Could not reach the fee database: DB password is hunter2"
log file: local.ERROR: Could not reach the fee database: DB password is hunter2 (the stack trace follows)
$ php artisan test --filter=ErrorPagesTest
FAIL Tests\Feature\ErrorPagesTest
⨯ a server error shows no exception details
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\ErrorPagesTest > a server error shows no exception…
The 500 page shows the exception message.
Failed asserting that true is false.
at tests/Feature/ErrorPagesTest.php:17
13▕ {
14▕ Route::get('/boom', fn () => throw new RuntimeException('Could not reach the fee database: DB password is hunter2'));
15▕
16▕ $page = $this->get('/boom')->assertStatus(500)->getContent();
➜ 17▕ $this->assertFalse(str_contains($page, 'hunter2'), 'The 500 page shows the exception message.');
18▕
19▕ $json = $this->getJson('/boom')->assertStatus(500);
20▕ $this->assertSame('Server Error', $json->json('message'), 'The JSON error shows the exception message.');
21▕ }
1 tests/Feature/ErrorPagesTest.php:17
Tests: 1 failed (2 assertions)
Duration: …s
[exit status 1]
$ export APP_DEBUG=false
$ php debug_probe.php
APP_DEBUG=false
browser: status 500, page shows the password: no, page title: Server Error
JSON client: status 500, body keys: message
message: "Server Error"
log file: local.ERROR: Could not reach the fee database: DB password is hunter2 (the stack trace follows)
$ php artisan test --filter=ErrorPagesTest
PASS Tests\Feature\ErrorPagesTest
✓ a server error shows no exception details
Tests: 1 passed (4 assertions)
Duration: …s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 debug_session.py
With debug on, the page showed the password, and the JSON body had five keys, message, exception, file, line and trace, which name your application’s files. With debug off, the page was the generic Server Error page and the body held only that message. The log line kept the full message in both runs, because reporting does not depend on debug mode. The Laravel documentation requires debug off in production.
The test turns that rule into a check: it failed while debug was on and passed once it was off. Run it with your production settings in the pipeline, and a deploy that would show details fails first.
A secret inside an exception message
Debug off kept the password off the page, but the log line still held it. Name what failed in a message, such as fee database unreachable, and never put credentials, tokens or personal data there.
Pages for the statuses you expect, such as 403 or 404, are Blade views in resources/views/errors/.
Checkpoint
Check: Reporting and rendering · What debug mode shows
Logging and context: one line per event, with its request
Logging in Laravel sends every line to a log channel: A named destination for log lines in config/logging.php, such as single (one file), daily (a file per day), stderr or stack, which forwards each line to a list of other channels., set up in config/logging.php. A new project’s stack channel forwards each line to the channels that LOG_STACK lists, here only single, the file storage/logs/laravel.log. The log level: The severity of a log line, from debug up to emergency. A channel writes only lines at or above its level, which Laravel reads from LOG_LEVEL. in LOG_LEVEL is the lowest level a channel writes, from debug up to emergency.
Context makes logging useful across a request. The Context facade stores values for the current request, and Laravel adds them to every log line written while it runs. This middleware stores a request id: A unique value given to one request and written on every log line it produces, and often returned in a response header, so that all the lines of that request can be found with one search., keeping the one a proxy sent when it looks valid:
context/app/Http/Middleware/AssignRequestId.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
// Gives every request an id, adds it to the context so that every log line of the request carries it, and returns it
// in a header so a support message can quote it. An id from the proxy is kept only if it looks like one.
class AssignRequestId
{
public function handle(Request $request, Closure $next): Response
{
$given = (string) $request->header('X-Request-Id');
$id = preg_match('/^[A-Za-z0-9-]{8,64}$/', $given) ? $given : (string) Str::uuid();
Context::add('request_id', $id);
$response = $next($request);
$response->headers->set('X-Request-Id', $id);
return $response;
}
} routes/web.php · context/routes/web.php
<?php
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
// A receipt page that writes two log lines: a detail for debugging, and the event itself.
Route::get('/instalments/{id}/receipt', function (string $id) {
Log::debug('Receipt total computed', ['instalment' => (int) $id, 'paise' => 118000]);
Log::info('Receipt viewed', ['instalment' => (int) $id]);
return response('Receipt for instalment '.$id);
}); bootstrap/app.php · context/bootstrap/app.php
<?php
use App\Http\Middleware\AssignRequestId;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(AssignRequestId::class); // global: every request gets an id first
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create(); """Logging with context: a global middleware adds the request id to the context, so every log line of the request
carries it; the same requests are sent again with LOG_LEVEL=info, which drops the debug line. The time at the start of
each log line is left out. Files: the folder context/ next to this file."""
from session import run
run(
"""
php context_probe.php
export LOG_LEVEL=info
php context_probe.php
""",
app=True,
overlay=("context",),
) Output
$ php context_probe.php
GET /instalments/42/receipt -> 200, X-Request-Id: req-1001-asha
GET /instalments/43/receipt -> 200, X-Request-Id: req-1002-kabir
storage/logs/laravel.log (LOG_LEVEL=debug):
local.DEBUG: Receipt total computed {"instalment":42,"paise":118000} {"request_id":"req-1001-asha"}
local.INFO: Receipt viewed {"instalment":42} {"request_id":"req-1001-asha"}
local.DEBUG: Receipt total computed {"instalment":43,"paise":118000} {"request_id":"req-1002-kabir"}
local.INFO: Receipt viewed {"instalment":43} {"request_id":"req-1002-kabir"}
$ export LOG_LEVEL=info
$ php context_probe.php
GET /instalments/42/receipt -> 200, X-Request-Id: req-1001-asha
GET /instalments/43/receipt -> 200, X-Request-Id: req-1002-kabir
storage/logs/laravel.log (LOG_LEVEL=info):
local.INFO: Receipt viewed {"instalment":42} {"request_id":"req-1001-asha"}
local.INFO: Receipt viewed {"instalment":43} {"request_id":"req-1002-kabir"}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 context_session.py
Every line ended with its request’s id, although no logging call mentioned one, so one search finds all that a request did. With LOG_LEVEL=info the debug lines were gone.
For servers and containers, the daily channel keeps a file per day and the stderr channel can write each line as JSON. A log processor: A function that Monolog runs on every log record before it is written. It can add fields to the record or change them, for example to mask a phone number. can mask phone numbers and tokens before a line is written. Values stored as hidden context: Values stored with Context::addHidden(). Code can read them with Context::getHidden(), but Laravel leaves them out of the log lines that carry the rest of the context. stay available to code and never appear in log lines.
Checkpoint
Check: Channels, levels and context
Exception classes that report and render themselves, throttled and critical errors, error pages and tests with Exceptions::fake() Pro: opens with a Pro, Premium or Ultimate pass
The skeleton’s one rule is a start. Exception classes can log and explain themselves, an outage can be quieted without hiding new bugs, and tests can keep the rules from drifting.
In this part:
- An exception class that logs itself and explains itself
- Throttling a noisy failure, and raising a serious one
- What every report carries, and what it should not
- Error pages and abort() for the statuses you expect
- Testing the rules with Exceptions::fake()
4 runnable examples (FeeAlreadyPaid.php, PHP, PaymentDeclined.php, Python, bootstrap/app.php, handling_probe.php, routes/web.php) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Channels for each environment, a processor that masks secrets with a failing test, and one request id from the web request into a queued job Pro: opens with a Pro, Premium or Ultimate pass
A log helps only when you can find the lines you need and nothing in it can hurt anyone. Channels can follow the environment, a processor that a test proves can mask secrets, and one request id can follow the work into a queued job.
In this part:
- Channels and levels for each environment
- A processor that masks phone numbers and tokens, proven by a failing test
- Deciding what a log line says
- One request id from the web request to the queued job
1 diagram · 4 runnable examples (AddRedaction.php, LogRedactionTest.php, Python, RedactSecrets.php, SendFeeReceipt.php, queue_probe.php, routes/web.php) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
Try both halves in your own Pathshala project:
- Add the
AssignRequestIdmiddleware with$middleware->append(...)inbootstrap/app.php, request a page, and find the id in theX-Request-Idheader and on its log lines. - Set
APP_DEBUG=false, request a route that throws, and compare the page and the JSON body. - Add
ErrorPagesTestto your tests and run it with debug on and with debug off.
Then take the self-check:
Practice that adapts to you
Practise this lesson
6 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: in Laravel, what is the difference between reporting an exception and rendering it?
Reporting records the exception for the team, by default as a log line with its message and stack trace, and it happens whatever the visitor sees. Rendering builds the response the visitor gets, an HTML page or JSON, and debug mode decides how much detail it shows. Both are configured in the withExceptions callback of bootstrap/app.php.
Three longer questions follow:
-
Mid to senior
A parent emails Pathshala a screenshot of an error page that shows a file path, a SQL query and some configuration values. Production was deployed an hour ago. Say what you do in the first hour, and what stops it from happening again.
-
Mid to staff
A guardian says a payment went through yesterday evening but no receipt SMS arrived. Pathshala runs three web servers and two queue workers, all logging JSON to standard error. Design logging that lets support answer in five minutes, and say what must never appear in those lines.
-
Mid to senior
During an SMS provider outage, Pathshala's error tracker receives 4,000 timeout reports an hour, and a new bug in fee calculation goes unnoticed for a day. Change the reporting rules so the outage stays visible without drowning everything else, and say what parents see meanwhile.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What two steps does Laravel's exception handler take with an exception?
Reporting records it, by default as a log line with the message and stack trace. Rendering turns it into the visitor's response, an HTML page or JSON. Both are set up in the withExceptions() callback of bootstrap/app.php.
-
Which tools in withExceptions() handle errors you expect?
dontReport() renders a kind of exception but never logs it, and throttle() keeps only some reports of a noisy one. An exception class may also carry its own report() and render() methods, which the handler calls instead.
-
What does a visitor see of a failing request with APP_DEBUG on and off?
On, which a new project's .env sets, the page showed the message with an invented password, and the JSON body had message, exception, file, line and trace. Off, the generic Server Error page and only that message. Production requires debug off.
-
Debug mode is off. Can a secret in an exception message still leak?
Yes, into the log: reporting does not depend on debug mode, so the log line kept the full message. Name what failed, such as fee database unreachable, and never put credentials, tokens or personal data in a message.
-
How do you stop a deploy that would show error details to visitors?
A test that checks the error response with production settings: it failed while debug was on and passed once it was off. Run it in the pipeline so the bad deploy fails first.
-
How does a new project's logging decide where a line goes and which lines are written?
config/logging.php sends lines to the stack channel, which forwards them to the channels in LOG_STACK, here single, the file storage/logs/laravel.log. LOG_LEVEL is the lowest level a channel writes, from debug up to emergency.
-
How do all the log lines of one request carry its request id?
A middleware stores the id with the Context facade, and Laravel adds context values to every log line written during that request, though no logging call mentions them. Hidden context stays available to code but never appears in log lines.
Key takeaways
- Laravel handles every exception in two steps: reporting writes it to the logs, rendering builds the page or the JSON the visitor receives.
- With
APP_DEBUG=truea 500 shows the message and your files to anyone, so production runs with debug off, and a test that searches the 500 response keeps it so. - Logging writes each line to a channel at a level, and the Context facade adds fields such as a request id to every line of a request.
Terms in this lesson
- exception handler
- The part of Laravel that receives every exception that leaves your code. It reports the exception, usually to the log, then renders the response the visitor gets. Its rules live in the withExceptions callback of bootstrap/app.php.
- exception rendering
- Turning an exception into the response a visitor receives: an HTML page, or JSON for a client that asks for it. Debug mode decides how much of the exception the response shows.
- exception reporting
- Recording an exception for the team, by default as a log line with its message and stack trace. It happens the same way whether debug mode is on or off.
- hidden context
- Values stored with Context::addHidden(). Code can read them with Context::getHidden(), but Laravel leaves them out of the log lines that carry the rest of the context.
- log channel
- A named destination for log lines in config/logging.php, such as single (one file), daily (a file per day), stderr or stack, which forwards each line to a list of other channels.
- log level
- The severity of a log line, from debug up to emergency. A channel writes only lines at or above its level, which Laravel reads from LOG_LEVEL.
- log processor
- A function that Monolog runs on every log record before it is written. It can add fields to the record or change them, for example to mask a phone number.
- request id
- A unique value given to one request and written on every log line it produces, and often returned in a response header, so that all the lines of that request can be found with one search.
References
- Error Handling (Laravel (laravel.com))
- Logging (Laravel (laravel.com))
- Context (Laravel (laravel.com))
- Error Handling Cheat Sheet (OWASP)
- Logging Cheat Sheet (OWASP)
- Illuminate\Foundation\Exceptions\Handler (13.x source) (Laravel (github.com/laravel))
- Illuminate\Log\Context\ContextServiceProvider (13.x source) (Laravel (github.com/laravel))
- Monolog (Jordi Boggiano (Monolog))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress