Logging, monitoring, and deployment — being able to trace what happened
Chapter 20 built a way to receive scheduled-task failures by email. What that chapter added already reaches you.
Four things still do not.
| When it happens | What happens now |
|---|---|
| A 500 is returned | One line in laravel.log. If nobody looks, that is that |
| A job fails | The same. Chapter 17 wrote Log::error, but it goes to the default log file |
| 404s come in a run | Nothing is recorded. It is among the exceptions Laravel ignores by default |
| A 429 is returned | Nothing is recorded. Information useful for tuning rate limits is lost |
This chapter does two things: make logs traceable after the fact, and gather the deployment steps into one procedure. The first half settles where things go and what they contain; the second lists the steps for shipping to production.
Docker configuration, server provisioning, and building CI/CD pipelines are out of scope. We stay on the Laravel side. For log destinations, we go as far as defining channels in config/logging.php.
Where logs go
The configuration is in config/logging.php. By default the stack channel is used, with one single inside it.
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
],
stack is a driver that only bundles channels. One call to Log::error() is delivered to every bundled channel.
The level applies per channel
Whether it is delivered is decided by each channel's level. There is a trap here.
Look at the slack channel definition Laravel provides.
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
The default level is critical. Without LOG_LEVEL set, Log::error() never reaches Slack. Nor does the Log::error('A job failed', ...) written in chapter 17.
LOG_STACK=daily,slack
LOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
That alone is not enough. Lower the channel's own level.
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'level' => 'error', // ← lowered from the default critical
'replace_placeholders' => true,
'processors' => [App\Logging\RedactSensitive::class], // ← built in a later section
],
processors is built later in this chapter. Write it on each channel bundled into stack. Writing it on stack has no effect, because what actually writes the log is the bundled channel.
Do not leave it as env('LOG_LEVEL', 'critical'). LOG_LEVEL is also read by single and daily. Drive both from one environment variable and the moment you lower the file's level to debug, everything floods into Slack.
Use daily for files
The default single keeps writing to one file. With no rotation, it fills the disk if left alone.
LOG_STACK=daily
LOG_DAILY_DAYS=14
daily splits files by date and deletes those past days. There is nothing principled about 14. Decide by how far back you go when investigating an incident and how much disk you have.
Tying requests and jobs into one thread
One log line on its own does not tell you what was happening around it. With 50 concurrent requests, the lines interleave.
Chapter 13 wrote one line when covering middleware registration.
$middleware->append(AddRequestId::class);
That class has not been implemented yet. We write it here.
Passing an ID through Context
<?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;
class AddRequestId
{
public function handle(Request $request, Closure $next): Response
{
Context::add('request_id', (string) Str::uuid());
$response = $next($request);
$response->headers->set('X-Request-Id', Context::get('request_id'));
return $response;
}
}
A value put into Context is automatically attached to every log line written during that request1. Callers of Log::info() do not have to pass it each time.
The same value goes back in a response header, so that when a user says "it just failed," you can ask for the header value and find the logs.
It is carried into the queue automatically
That is why we chose Context.
Log::withContext(['request_id' => $id]); // only within the request
Context::add('request_id', $id); // reaches queued jobs too
Log::withContext() only adds context to the channel and never reaches a worker in a separate process. Context captures its contents when a job is dispatched and restores them when the job runs2.
When the NotifyWarehouse built in chapter 16 fails, its log carries the request_id of the request that accepted the order. The API's logs and the worker's logs are joined by one ID.
As chapter 20 confirmed, Schedule::command() starts php artisan in a separate process via Symfony's Process. Context does not cross that boundary. Scheduled-task logs carry no request_id, so you trace those by command name plus run time.
How it relates to error_id
Chapter 9 issued an error_id when returning a 500. Its role differs from request_id.
| ID | Unit | Shown to the user |
|---|---|---|
request_id | One request (and the jobs it queued) | Returned in a response header |
error_id | One exception | Returned in the response body |
Two exceptions in one request produce two error_ids and one request_id. You receive an error_id in a support ticket and trace around it with request_id.
Chapter 9's implementation gets one line shorter now that Context is in place.
$errorId = (string) Str::uuid();
Context::add('error_id', $errorId); // ← attached to logs automatically
Log::error($e->getMessage(), ['exception' => $e]); // no need to pass error_id by hand
What to keep and what not to
Chapter 9 separated expected from unexpected errors. Its table said expected errors are not logged (or logged at info level). Here we take the latter.
Expected errors go to the info destination
Chapter 9's real implementation kept insufficient stock out of the logs.
$exceptions->report(function (InsufficientStockException $e) {
return false;
});
Returning false keeps it out of the default log stack. Not one line about insufficient stock survives. That was to avoid thousands of errors on the day a popular product sells out burying the exceptions you actually want to investigate.
But "we want to know how often it happens" is a legitimate request. Satisfy both.
$exceptions->report(function (InsufficientStockException $e) {
Log::channel('daily')->info('Insufficient stock', [
'product_id' => $e->productId,
'requested' => $e->requested,
'available' => $e->available,
]);
return false;
});
Log::channel('daily') names the destination. Writing Log::info() sends it to the default stack, and with Slack bundled it goes there too. Sending expected errors to Slack is pointless, so it lands in the file only.
Keep the return false. Without that line, after report it also flows to the default stack as an error. Suppressing and recording are different things.
404 is ignored by default
Chapter 13 said "a run of 404s from the same user is abnormal." Trying to record them hits a wall first.
Laravel does not report 404s by default. 404, 403 (origin mismatch), and 419 (CSRF) are on the framework's list of ignored exceptions3. Writing report() does not get it called.
Remove them with stopIgnoring().
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
$exceptions->stopIgnoring([
ModelNotFoundException::class, // thrown by findOrFail
HttpException::class, // abort() and 429
]);
$exceptions->report(function (ModelNotFoundException $e) {
Log::channel('daily')->info('404', [
'path' => request()->path(),
'ip' => request()->ip(),
]);
return false;
});
NotFoundHttpException does not catch it. Every 404 in this series since chapter 12 comes from findOrFail. findOrFail throws ModelNotFoundException, and the swap to NotFoundHttpException happens on the response-building side. What reaches report is the exception before the swap4.
stopIgnoring() removes by exact class name. The ignore list holds ModelNotFoundException and HttpException as separate entries. Remove one and the other stays ignored.
The 419 returned for a CSRF token mismatch is also on the ignored side, but what is thrown is TokenMismatchException. It is a separate entry from HttpException, so the settings above do not remove it.
Pass the exception actually thrown to stopIgnoring. Working backwards from the response status code misses.
Removing HttpException has broader effects than 404. abort(403) and abort(405) all become reportable too. Kinds you do not catch in report() flow to the default stack as error, so check the log volume right after adding it.
Chapter 9's render early-returns on HttpExceptionInterface, so the response shape does not change. Only the logs do.
429 rides the same path
ThrottleRequestsException, thrown when a rate limit fires, extends Symfony's TooManyRequestsHttpException. Being a descendant of HttpException, removing HttpException above already made it reportable. All you add is a report.
use Illuminate\Http\Exceptions\ThrottleRequestsException;
$exceptions->report(function (ThrottleRequestsException $e) {
Log::channel('daily')->info('Rate limited', [
'path' => request()->path(),
'ip' => request()->ip(),
'user_id' => request()->user()?->id,
]);
return false;
});
This is what chapter 13 meant by "log how often 429s occur so you can adjust later." user_id is kept because RateLimiter::for() narrowed per user. Without knowing who is hitting the limit, you cannot judge whether to raise the threshold or whether that user is anomalous.
The level mapping
That settles how the three levels are used.
| Level | What goes in | Destination |
|---|---|---|
error | Unexpected, needs action (500, job failure) | daily + slack |
warning | Expected, but you want to watch the frequency (a transient external API failure) | daily |
info | Kept as a record (insufficient stock, 404, 429) | daily only |
Only error reaches a person. Everything else is there to be aggregated later. Without that line, Slack keeps pinging and nobody looks.
Dropping sensitive data
Chapter 11 gave an example of writing a token into the logs.
Log::info('Login succeeded', ['token' => $token->plainTextToken]);
Not passing it at the call site is the surest fix. But that is a matter of discipline, not machinery. As a team grows, someone eventually writes it.
Drop it with a processor
Monolog lets you insert a processor that transforms a record before it is written.
<?php
namespace App\Logging;
use Monolog\LogRecord;
class RedactSensitive
{
private const KEYS = ['token', 'password', 'secret', 'authorization', 'api_key'];
public function __invoke(LogRecord $record): LogRecord
{
return $record->with(context: $this->redact($record->context));
}
private function redact(array $context): array
{
foreach ($context as $key => $value) {
if (in_array(strtolower((string) $key), self::KEYS, true)) {
$context[$key] = '[redacted]';
} elseif (is_array($value)) {
$context[$key] = $this->redact($value);
}
}
return $context;
}
}
Register it on the channel.
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'processors' => [App\Logging\RedactSensitive::class],
],
It drops by key name. You could judge by the shape of the value (a JWT-looking string, say), but a false positive leaves you not knowing what disappeared. With key names, the place that was dropped remains visible as [redacted].
It recurses because values arrive in nested arrays. Passed as ['user' => ['token' => '...']], it still gets dropped.
Write Log::info("The token is {$token}") and the value is not dropped. A processor sees only the context. You could rewrite $record->message, but then you are deciding what is a token from a string.
Settle on a convention that sensitive data goes in the context. Machinery can only take you that far.
It is not the same as Context's hidden
Context has addHidden(), and values put there do not appear in logs.
Context::addHidden('access_token', $token); // never appears in logs
That is not masking. It is a feature for "not emitting values put into Context," and it has no effect on values passed directly, as in Log::info(..., ['token' => ...]). It does not close the Bad example above.
The split is this: sensitive values you want carried across a request go in addHidden; dropping a value that got written into a log is the processor's job.
Sending structured logs
The logs so far are written in the default format.
[2026-08-26 10:15:32] production.ERROR: Something went wrong on our side. {"exception":"[object] (RuntimeException...)"} {"request_id":"9f8e...","error_id":"3a2b..."}
Fine for a person to read. Not suited to machine aggregation. The timestamp and level are at the start of the line, the context is JSON at the end, and the two use different formats.
One line, one JSON object
Swap Monolog's formatter. Writing tap in a channel definition lets you modify the Monolog instance.
<?php
namespace App\Logging;
use Illuminate\Log\Logger;
use Monolog\Formatter\JsonFormatter;
class UseJsonFormatter
{
public function __invoke(Logger $logger): void
{
foreach ($logger->getHandlers() as $handler) {
$handler->setFormatter(new JsonFormatter());
}
}
}
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'handler_with' => ['stream' => 'php://stderr'],
'tap' => [App\Logging\UseJsonFormatter::class],
'processors' => [App\Logging\RedactSensitive::class],
'level' => env('LOG_LEVEL', 'debug'),
],
Each line becomes one JSON object.
{"message":"Something went wrong on our side.","context":{"exception":"..."},"level":400,"level_name":"ERROR","channel":"production","datetime":"2026-08-26T10:15:32+09:00","extra":{"request_id":"9f8e...","error_id":"3a2b..."}}
You can search by request_id. Filter on extra.request_id in your log aggregation and you get the logs for one request plus the jobs it queued.
Why standard error
The destination is php://stderr — not a file, but the process's standard error output.
Running in containers, logs written to a file disappear when the container does. Standard output and standard error are collected by the runtime, so sending them there is natural. CloudWatch Logs connects by collecting container output too.
LOG_STACK=stderr
Which channels you bundle depends on whether you run on files or in containers.
| Environment | LOG_STACK |
|---|---|
| Local | single (one file, easy to follow) |
| Staging / production (servers) | daily,slack |
| Production (containers) | stderr,slack |
Sending to an external service
Chapter 9 said "routing to files, Sentry, CloudWatch, and so on is covered in chapter 21." Defining channels is essentially all of it.
CloudWatch connects through the stderr above. When adding a dedicated package such as Sentry, the package adds a channel and you put it in the stack.
LOG_STACK=stderr,sentry
The package installation procedure itself is out of scope here. From Laravel's point of view it is just one more channel, and the stack thinking does not change.
Some output never goes through a channel
Chapter 20 appended scheduled-task output to a file.
Schedule::command('images:prune-orphans')
->dailyAt('03:10')
->appendOutputTo(storage_path('logs/prune-orphans.log'));
That file does not pass through Monolog. It writes the command's standard output via a shell redirect, so adding slack to the stack does not send it there, and processors do not apply. It does not become JSON either.
To get scheduled-task output onto your monitoring, move toward calling Log:: inside the command. Chapter 20's emailOutputOnFailure sits in the same place: an independent mechanism that just sends mail when the exit code is non-zero.
| Path | Goes through a channel |
|---|---|
Calls such as Log::error() | Yes (processors and formatters apply) |
sendOutputTo / appendOutputTo | No (raw text) |
emailOutputOnFailure | No (it only branches on the exit code) |
Chapter 20's "both email and files assume someone goes and looks" comes from this difference. Only paths that go through a channel can be made to come to you.
The deployment procedure
Five things have been marked "put this in your deployment procedure" across the previous chapters. Let us gather them.
Four run on every deployment, and two run once when building an environment. Mixing them causes accidents.
Every deployment
# 1. Install dependencies (excluding dev)
composer install --no-dev --optimize-autoloader
# 2. Run migrations
php artisan migrate --force
# 3. Cache the configuration
php artisan config:cache
# 4. Cache the routes
php artisan route:cache
# 5. Swap the workers
php artisan queue:restart
The order matters.
--force on migrate skips the "yes, this is production" confirmation, as covered in chapter 3. Waiting for an interactive confirmation would stall the deployment.
config:cache comes after migrate. As chapter 2 covered, running that command stops .env from being read. When you change a setting, the order is: update .env, then run config:cache.
With a scheme that runs git pull in the same directory, bootstrap/cache/config.php from the previous deployment is still there. migrate --force reads it. On the deployment right after changing the connection, it runs migrations against the old connection.
When you change the DB settings in .env, run php artisan config:clear first, or move config:cache before migrate. For deployments with no setting changes, the order above is fine.
After config:cache, env() returns a server-level environment variable or null. Chapter 2's decision to call env() only inside config/ exists because of this step.
You do not run config:cache locally, so code that broke the rule picks up null only in production. It is a failure that appears only after deployment.
queue:restart is last. It is what chapter 17 called "forget this one line and the deployment changes nothing." A worker loads its code at startup, so placing new code leaves running workers executing the old code.
Run queue:restart before building the caches and the restarted workers read the old config cache. Swap them once every cache is updated.
Once, when building the environment
# Create the link to the public disk
php artisan storage:link
# Register the scheduler with cron
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
"Once" holds under a condition. With the repository in one place and git pull there, once is enough.
With a scheme that creates directories like releases/20260826_101532/ and repoints a current symlink, public/storage disappears on every release, because it does not exist in the new release directory.
| Deployment scheme | storage:link |
|---|---|
git pull in the same directory | Once, at environment build time |
| A directory per release | Include it in every deployment |
That is why chapter 19 said "put this command in your deployment procedure." Determine which scheme you use, then decide which row of the table applies.
Registering cron is a one-time job when building the server. Adding scheduled work to routes/console.php does not change the cron line — chapter 20 was arranged that way.
Noticing that something has stopped
Chapter 2 showed that bootstrap/app.php includes health: '/up'.
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
Hitting /up tells you whether the application can boot. It is meant to be polled periodically by an external monitoring service.
What /up cannot answer
Even a 200 from /up leaves things unknown.
- Is the queue worker running? Requests are served even when it is stopped
- Is the scheduler running?
/upreturns 200 even with cron stopped
The end of chapter 20 said "you cannot notice that schedule:run has stopped." Here is the answer.
Record the last successful run
Record that the scheduled task ran, and be told when that record gets old.
Schedule::command('orders:cancel-stale')
->everyFiveMinutes()
->withoutOverlapping(10)
->onOneServer()
->onSuccess(fn () => Cache::forever('heartbeat:cancel-stale', now()->toIso8601String()));
onSuccess() is called when the command exits with code 0. Write the time into the cache.
Put the reading side on an endpoint separate from /up.
Route::get('/health/scheduler', function () {
$last = Cache::get('heartbeat:cancel-stale');
if ($last === null || Carbon::parse($last)->lt(now()->subMinutes(15))) {
return response()->json(['status' => 'stale', 'last_success' => $last], 503);
}
return response()->json(['status' => 'ok', 'last_success' => $last]);
});
The threshold is 15 minutes for work that runs every 5, so a single miss does not fire it. Three consecutive failures counts as an anomaly.
As chapter 2 settled, this series' routes live only in routes/api.php. Chapter 4 set apiPrefix: 'api/v1', so the actual path is /api/v1/health/scheduler. Register that with your monitoring service.
Register "notify when the time gets old" as a Schedule::command() and the check itself does not run when cron is stopped. Health checking has to live outside the thing being checked.
In this form, an external monitoring service hits /health/scheduler and sees the 503. All Laravel can do is be ready to answer when asked.
Tests
Logging tests split into two layers: the layer that stops at the facade, and the layer running inside Monolog. One style cannot see both.
Seeing what was recorded
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
pest()->use(RefreshDatabase::class);
test('insufficient stock is recorded at info and not at error', function () {
Log::shouldReceive('channel')->with('daily')->andReturnSelf();
Log::shouldReceive('info')->once();
Log::shouldNotReceive('error');
$stock = Stock::factory()->soldOut()->create();
$this->postJson('/api/v1/orders', [
// order one unit of a product with zero stock
])->assertStatus(409);
});
andReturnSelf() is required. Log::channel('daily')->info(...) is a two-step call, so unless the channel() mock returns itself you end up calling info() on null.
shouldNotReceive('error') is the substance. Remove the return false from report and it flows to the default stack as error, failing here. Chapter 9's decision that expected errors do not appear at error level is pinned down by that one line.
Calling the processor directly
A processor runs inside Monolog, so it never reaches a facade mock. Call the class directly.
use App\Logging\RedactSensitive;
use Monolog\Level;
use Monolog\LogRecord;
test('sensitive data is dropped', function () {
$record = new LogRecord(
datetime: new DateTimeImmutable(),
channel: 'test',
level: Level::Info,
message: 'test',
context: ['token' => 'secret-value', 'user_id' => 1],
);
$result = (new RedactSensitive())($record);
expect($result->context['token'])->toBe('[redacted]');
expect($result->context['user_id'])->toBe(1);
});
Even this cannot confirm it is registered on a channel. Drop it from processors in config/logging.php and this test still passes. The contents of the configuration file are outside the tests, so confirm by eye.
What matters in production
Do not reuse one LOG_LEVEL variable everywhere. With single, daily, and slack all reading the same env('LOG_LEVEL'), lowering the file's level floods Slack. Hard-code the value on the Slack side, or use a separate variable.
stopIgnoring(HttpException::class) has a wide reach. Add it to catch 429s and abort(403) and abort(405) become reportable too. Kinds you do not catch in report flow to the default stack as error, so check the log volume right after adding it. 419 is a different class (TokenMismatchException) and is not removed this way.
After config:cache, changes to .env have no effect. Change a setting and always re-run config:cache. Since you do not run it locally, that difference becomes an incident.
A processor does not see the message body. Logs with sensitive data embedded in a string are not dropped. "Sensitive data goes in the context" remains a convention held by people rather than machinery.
Logs past daily's days are deleted. Sometimes an investigation wants a month ago. If you need long-term retention, keep it on the log aggregation side. File rotation is not retention.
Put health check endpoints outside authentication. Put /health/scheduler behind auth:sanctum and your monitoring service has to hold a token. Exclude it from chapter 13's rate limiting too — monitoring hits it every minute.
Summary
- Logging configuration is
config/logging.php.stackonly bundles channels, and whether something flows is decided by each channel'slevel slack's default level iscritical. Without lowering it,Log::error()never arrives- Use
dailyrather thansingle. Without rotation it fills the disk - Pass the request ID through
Context.Log::withContext()does not reach a worker in a separate process Contextvalues are attached to every log line automatically and carried into queued jobs. The API's and the worker's logs join on one ID- It does not reach the scheduler's child processes. Trace scheduled tasks by command name and time
request_idis per request;error_idis per exception. You receive the latter in a ticket and trace with the former- For expected errors,
Log::channel('daily')->info()insidereport()and thenreturn false. Suppressing and recording can coexist - Laravel ignores 404 by default. A
findOrFail404 is aModelNotFoundException, so pass that class tostopIgnoring. Working backwards from the status code misses - What reaches
reportis the exception before the swap. The conversion toNotFoundHttpExceptionhappens on the response-building side - Send only
errorto people; keepwarningandinfofor aggregation. Without that line, Slack keeps pinging and nobody looks - Drop sensitive data with a Monolog processor.
Context::addHidden()is for "not emitting values put into Context" and has no effect on values passed directly - A processor sees only the context. A value embedded in the message body is not dropped
- In containers, write logs to
php://stderrrather than a file. The file disappears with the container - Deploy as
composer install→migrate --force→config:cache→route:cache→queue:restart. Swap workers after every cache is built - Whether
storage:linkis a one-time job depends on the deployment scheme. A directory per release needs it every time /upanswers only "can the app boot." Health-check the worker and the scheduler separately- Do not put the health check inside a scheduled task. With cron stopped, the check itself does not run
What to read next
The next chapter, Summary — a pre-release checklist, is the final one. It gathers what the previous 21 chapters settled into one list to run through before shipping to production. The deployment procedure laid out here is part of it. It is arranged so you can trace which chapter decided what, which also makes it a useful entry point when coming back to the guide.
Exercises
The following logging configuration has problems. Point them out
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 3,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'level' => env('LOG_LEVEL', 'debug'),
],
Sample answer
slack's level is debug. Without LOG_LEVEL set, the default debug applies and every log flows into Slack. Log::info() and Log::debug() both arrive. As the body covered, once that happens the notifications are meaningless within minutes.
The cause is that daily and slack read the same environment variable. Wanting debug on the file side is legitimate, so separate the Slack side.
days is 3. Things vanish after three days. Investigate a Friday-night incident on Monday and it is already gone. You need a span that survives a weekend.
With no processors, sensitive data is not dropped. The RedactSensitive built in the body is registered per channel. Writing it on stack has no effect — stack only bundles, and the bundled channels do the writing.
ignore_exceptions is omitted. The default is false, so behavior is unchanged, but stating how a throwing channel inside a stack is handled is kinder to readers. Whether the whole log fails when the Slack webhook is down is decided by that value.
You get a report that "we deployed but the new code is not running." What do you check first?
Sample answer
The symptom separates the cases. Which part is stale points at a different cause.
If API responses are stale, it is route:cache or config:cache. Add or change a route without rebuilding the cache and the old definitions are used. The same for configuration. Check whether it is in the deployment procedure.
If job behavior is stale, it is queue:restart. As the body covered, a worker loads its code at startup. The signature is that the API is new and only jobs are old. Chapter 17 covered that one line because this symptom is the hardest to read.
If scheduled tasks are stale, check that the routes/console.php change was deployed. The cron line does not change, so there is no need to suspect it.
If everything is stale, the code was not deployed. A failed symlink swap, a git pull on the wrong branch — problems earlier in the chain.
php artisan about is the fastest first check. It lists which caches are in place. If it says "Config: CACHED" while the configuration is stale, you know the cache was not rebuilt.
To stop the symptom recurring, script the deployment procedure rather than typing it by hand. Beyond that is CI/CD, which this series does not cover.