The task scheduler — collapsing scheduled work into a single cron line
Across the previous chapters, work we wrote off as "we will clean this up later" has piled up.
Expired tokens from chapter 11, negative stock from chapter 14, orphaned files from chapter 19, and the order cancellation chapter 19 promised. All four are processing nobody calls but that you want to run. It is not called from an API request, nobody hits it, and you want it to run on its own when the time comes.
Built straightforwardly, that means adding four lines to the server's crontab. This chapter adds one line and keeps the rest on the application side. What was added where stays in git log — that is why we choose this shape.
The last one, cancelling unpaid orders, differs in nature from the other three. It is a state change rather than a deletion, and getting it wrong deletes paid orders. This chapter's longest section goes there.
Only one line goes in cron
Schedules go in routes/console.php. That file was already registered in the bootstrap/app.php we saw in chapter 2.
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
Let us write one scheduled task.
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('sanctum:prune-expired --hours=24')->daily();
On the server, put this one line in crontab1.
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
schedule:run starts every minute. Once started, it decides whether anything is due at this moment and, if not, exits doing nothing. Something written with daily() actually runs once a day.
Why not four lines in crontab
To register four pieces of work, you can write four lines in cron, or one cron line plus four entries on the application side. The result is the same. What differs is where the change history lives.
Write it in cron directly and changing a schedule means an ssh session. Who changed what and when lives only in the server's crontab, and nothing is reviewed. Staging and production can drift apart with nobody noticing until they are compared.
Write it in routes/console.php and changing a schedule becomes a code change. It goes into a pull request, passes review, and ships with a deployment. You touch cron once, at the start.
Running it locally
Do not register cron locally. Use schedule:work instead.
php artisan schedule:work
It stays in the foreground and does what cron would do. Stop it with Ctrl-C.
To check what you have registered, list it.
php artisan schedule:list
It even prints the next run time, so use it to check whether the frequency you meant matches the frequency you got. Mistyping daily() for dailyAt('03:00') is not a syntax error, so you need a way to confirm by eye.
Pruning expired tokens
Chapter 11 set an expiry on Sanctum tokens. An expired token stops authenticating, but the record stays, because nobody is responsible for deleting it.
Sanctum ships a command for that.
Schedule::command('sanctum:prune-expired --hours=24')->daily();
--hours=24 means "delete those that expired 24 hours ago." Not deleting them the instant they expire leaves room for investigation. When a "I cannot log in" ticket comes in, a surviving record confirms it was expiry.
That is all this section does. When a ready-made command exists, the scheduler's job is only deciding when to run it.
Finding and reporting negative stock
Chapter 14 considered forbidding negative stock with a CHECK constraint and decided not to add it. The normal order path is closed by the conditional UPDATE, and what remains is writes from admin screens and migration scripts.
At the time we wrote that we would "supplement with a mechanism that periodically searches for negative stock and notifies you." We build it here.
Creating the command
This is the first custom Artisan command in the series.
php artisan make:command CheckNegativeStock
That creates app/Console/Commands/CheckNegativeStock.php.
<?php
namespace App\Console\Commands;
use App\Models\Stock;
use App\Notifications\NegativeStockDetected;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;
class CheckNegativeStock extends Command
{
protected $signature = 'stock:check-negative';
protected $description = 'Search for products with negative stock and report them';
public function handle(): int
{
$negatives = Stock::where('quantity', '<', 0)->get();
if ($negatives->isEmpty()) {
$this->info('No negative stock.');
return self::SUCCESS;
}
Notification::route('mail', config('services.ops.mail'))
->notify(new NegativeStockDetected($negatives));
$this->error("There are {$negatives->count()} products with negative stock.");
return self::FAILURE;
}
}
$signature is the command name. The stock:check-negative shape reads as "subject:verb," matching sanctum:prune-expired.
The return value is the exit code. Returning self::FAILURE when something is found feeds the "noticing failures" mechanism covered later, which looks at exit codes. self::SUCCESS is 0 and self::FAILURE is 1.
The notification's contents follow chapter 15's form and are omitted. The recipient is given directly with Notification::route() because the reader is an operator, not a user. That keeps operators out of the User model.
Add the recipient to config/services.php. As chapter 2 settled, env() is called only inside config/.
'ops' => [
'mail' => env('OPS_MAIL_ADDRESS'),
],
Putting it on the schedule
Schedule::command('stock:check-negative')->hourly();
The frequency is hourly() because negative stock means the real stock and the books have diverged. Leave it a day and every order in that window is processed on top of a wrong stock count. As chapter 14 said, it does not happen through the normal path, so this running at all is an anomaly — better found early.
Sweeping up orphaned files
Chapter 19 decided the order of writing a file and a row by "leaning toward the quiet failure." Write the file first, and a failed row update leaves a file nobody references.
There are three paths to an orphaned file.
| Path | What happens |
|---|---|
| Failure partway through saving | The file was written but image_path was not updated |
| A failed delete on replacement | The old file remains |
| A bulk delete | Product::where(...)->delete() skips the observer and the file remains |
All three end the same way. The file is on the disk and nothing in products.image_path points at it.
You cannot work backwards from the database
Before writing the sweep command, decide how to find them.
Chapter 19 kept random names for product images. Fixing them at products/{product ID}.jpg would freeze the URL and make a CDN keep serving the old image.
The price of that decision shows up here. You cannot derive the product ID from the filename. Looking at products/8fK2mZ.jpg on the disk tells you nothing about which product it belonged to, or whether it belonged to a product at all.
And on the bulk-delete path, the corresponding products row no longer exists. A search starting from rows cannot find the very thing you most want to find.
One approach remains. List every file on the disk and subtract the values in products.image_path. What is left over is orphaned.
protected $signature = 'images:prune-orphans';
protected $description = 'Delete image files no product references';
public function handle(): int
{
$referenced = Product::whereNotNull('image_path')
->pluck('image_path')
->flip();
$threshold = now()->subHours(1);
$deleted = 0;
foreach (Storage::disk('public')->files('products') as $file) {
if ($referenced->has($file)) {
continue;
}
if (Storage::disk('public')->lastModified($file) > $threshold->timestamp) {
continue;
}
Storage::disk('public')->delete($file);
$deleted++;
}
$this->info("Deleted {$deleted} orphaned files.");
return self::SUCCESS;
}
flip() follows pluck(). Moving values into keys makes has() a hash lookup. Left as contains(), it scans the array once per file, getting slower as products accumulate.
Why a one-hour grace period
Looking at lastModified, files written within the last hour are kept.
Chapter 19's save writes the file and then updates the row. Between those two there is a short gap. Run this command in that gap and you delete a file that is in the middle of normal processing. The row update then succeeds, leaving "the row points at a path and the file is gone" — the most visible failure chapter 19 wanted to avoid.
An hour was chosen because an upload never takes an hour. There is no principled minimum for this number. It needs to be comfortably longer than the time between the file write and the row update, and there is no reason to make it shorter.
Schedule::command('images:prune-orphans')->dailyAt('03:10');
The time is offset with dailyAt('03:10') so that overnight work does not all land in the same minute. Scanning the whole disk is heavy, and overlapping with other work means fighting over I/O.
Deciding what "unpaid" means
Chapter 19 said "auto-cancelling unpaid orders is built there too." This is that section.
What we build looks simple: cancel orders that have gone unpaid for a while and return the reserved stock. Before implementing, we settle how "unpaid" is decided. Get it wrong and you cancel paid orders and return their stock.
status does not mean unpaid
orders.status holds OrderStatus::Pending. As the name says, it is pending.
But look through all the code in this series and nothing ever writes Confirmed into status. Creating an order sets Pending and that is it. Chapter 17 received the payment webhook and created a payments row, and chapter 19 added receipt generation, but neither touches status.
In other words, a paid order is still Pending. Filter on status === Pending and every paid order is included.
Close both holes
There are two things to do.
One is to advance status once payment lands. Chapter 17's exercise covered "a job that updates the order status," but it was not in the real implementation. We add it now.
public function handle(): void
{
$payment = Payment::firstOrCreate(
['provider_event_id' => $this->eventId],
['order_id' => $this->orderId, 'amount' => $this->amount],
);
if (! $payment->wasRecentlyCreated) {
return;
}
Order::whereKey($payment->order_id) // ← added
->where('status', OrderStatus::Pending) // ← added
->update(['status' => OrderStatus::Confirmed]); // ← added
NotifyWarehouse::dispatch($payment->order);
GenerateReceipt::dispatch($payment->order);
}
The bottom two lines were added in chapters 17 and 19. Receipt generation came in chapter 19, so do not drop it when copying this across.
Placing it after the wasRecentlyCreated guard means the same webhook arriving twice runs it once. The idempotency built in chapter 17 applies directly.
Advancing only from Pending keeps a cancelled order from being revived. Payment can arrive after the batch in this chapter has already cancelled an order. Write unconditionally and you overwrite Cancelled with Confirmed, so stock stays returned while a shipping instruction goes out.
It does not read before writing. When chapter 17's exercise covered "process only when it is not yet Confirmed," it said "that alone still lets both through when two workers run at once." The same applies here. Read status between the batch issuing its cancellation UPDATE and committing, and it still looks Pending. Put the condition in the WHERE and the decision happens at the instant the database updates the row.
Even so, the bottom two lines do not stop. Guarding the status still leaves the warehouse notification and receipt generation running whenever wasRecentlyCreated is true. That remaining path is addressed at the end of this section.
The other is to include payments in the cancellation condition. Updating the status is not enough on its own. Right after receiving the webhook and queueing the job, an order not yet processed is still Pending. As chapter 17 showed, queues back up.
Add payments() to Order. It is the same form as adding user() in chapter 15.
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
The gap between selecting and updating
With the condition settled, write the cancellation. Written straightforwardly, it breaks.
$orders = Order::where('status', OrderStatus::Pending)
->whereDoesntHave('payments')
->where('created_at', '<', now()->subMinutes(30))
->get();
foreach ($orders as $order) {
$order->update(['status' => OrderStatus::Cancelled]);
$stockChecker->restore($order->items->toArray());
}
payments may be empty at selection time, but a webhook arriving mid-loop inserts a row. The update does not see that change, so it cancels a paid order and returns its stock.
The fix is the same as chapter 14. Put the decision in the UPDATE's WHERE and look at the affected row count.
public function __construct(private readonly StockChecker $stockChecker) {}
public function cancelStale(Order $order): void
{
DB::transaction(function () use ($order) {
$affected = Order::whereKey($order->id)
->where('status', OrderStatus::Pending)
->whereDoesntHave('payments')
->update(['status' => OrderStatus::Cancelled]);
if ($affected === 0) {
throw new OrderNotCancellableException($order->id);
}
$this->stockChecker->restore($order->items->toArray());
}, attempts: 3);
}
It is wrapped in DB::transaction(). It updates one orders row and as many stocks rows as there are lines — it spans several rows. If the second line's increment fails, the order is Cancelled while only some stock came back. And the next run excludes it via the status = Pending condition, so it is never picked up again.
Chapter 14 said "gathering operations that span several rows is the transaction's job; preventing a conflict on one row is the conditional UPDATE's job." This is a place that needs both.
attempts: 3 is the form settled in chapter 14 too. It re-runs the closure from the top only on a deadlock. OrderNotCancellableException is not a retry target.
It works only on the outermost transaction. As chapter 14 showed, an inner one becomes a SAVEPOINT and is not retried. If the caller has already opened a transaction, this setting does nothing. The same applies to tests using RefreshDatabase.
Stock is returned only when the affected count was 1. As chapter 17's exercise showed, increment grows by however many times it ran. Return stock when the UPDATE affected zero rows and stock grows for an order that was never cancelled.
Chapter 14's decrease() could carry where('quantity', '>=', ...), but the returning side has no corresponding condition. There is no upper bound on "increasing too much." So we narrow it to once on the state transition side.
The returning side is the counterpart of chapter 14's decrease().
public function restore(array $items): void
{
usort($items, fn ($a, $b) => $a['product_id'] <=> $b['product_id']);
foreach ($items as $item) {
Stock::where('product_id', $item['product_id'])
->increment('quantity', $item['quantity']);
}
}
Unlike decrease(), it does not look at the affected count, because there is no condition under which returning fails. If the product itself is gone you get zero rows, and then there is nowhere to return stock to anyway.
It sorts in the same order as confirming an order. In chapter 14 OrderPlacementService sorted lines by product_id before calling decrease(). What that chapter meant by "if other processing touches stock, it needs the same order" is exactly this. When confirmation and cancellation run at once, one grabbing stocks as product 1 → product 2 and the other as product 2 → product 1 is a deadlock.
The exception class carries only state.
class OrderNotCancellableException extends Exception
{
public function __construct(public readonly int $orderId)
{
parent::__construct("Order {$orderId} is not in a cancellable state.");
}
}
Conversion to HTTP is gathered in bootstrap/app.php, as chapter 9 settled. We do not write render() on the exception class.
$exceptions->render(function (OrderNotCancellableException $e, Request $request) {
return response()->json([
'message' => $e->getMessage(),
'code' => 'ORDER_NOT_CANCELLABLE',
'details' => ['order_id' => $e->orderId],
], 409);
});
$exceptions->report(function (OrderNotCancellableException $e) {
return false;
});
Placing render and report as a pair is the form settled for InsufficientStockException in chapter 9. Skipping an order that got paid mid-processing is an expected event, so it does not go to the log.
Neither is called yet. The batch in this chapter catches the exception itself, so it never reaches Laravel's handler. It is set up so that 409 and log suppression work as soon as a cancellation endpoint is added.
Both conditions are there because they change in different ways.
Once the payment job runs, status becomes Confirmed, so where('status', Pending) acts as the guard. But at the stage where only the job has been queued, the only thing present is the payments row. whereDoesntHave('payments') closes that.
When you use a conditional UPDATE as a safeguard, check that "what changes between selection and update" matches "what the WHERE looks at." Condition on a column nobody moves and the UPDATE always hits its one row without guarding anything.
Why throw an exception
cancelStale() throws when it could not cancel. It does not return a bool.
Chapter 12 decided that a cancellation in an invalid state returns 409, and said "throw an exception in the service class and convert it to 409 with chapter 9's mechanism." It has to behave the same way when this service is called from an HTTP path.
That said, this series has no cancellation API endpoint yet. The only caller of cancelStale() right now is this chapter's batch, and the exception goes no further than the batch. It is set up so that a 409 comes back when an endpoint is added later.
Callers should load items before passing the order. cancelStale() touches $order->items, so handing it an Order straight from route model binding gives you the lazy loading exception covered next.
Do not let one failure stop everything
The loop lives in the command.
protected $signature = 'orders:cancel-stale';
protected $description = 'Cancel orders unpaid for a set time and return their stock';
public function handle(OrderCancellationService $service): int
{
$cancelled = 0;
$skipped = 0;
Order::with('items')
->where('status', OrderStatus::Pending)
->whereDoesntHave('payments')
->where('created_at', '<', now()->subMinutes(30))
->chunkById(100, function ($orders) use ($service, &$cancelled, &$skipped) {
foreach ($orders as $order) {
try {
$service->cancelStale($order);
$cancelled++;
} catch (OrderNotCancellableException) {
$skipped++;
}
}
});
$this->info("Cancelled {$cancelled} / skipped {$skipped}");
return self::SUCCESS;
}
It catches the exception one order at a time. Without that, one order out of 100 getting paid mid-processing leaves the remaining 99 unprocessed. The next run picks them up, but one legitimate conflict stopping 99 orders is a bad trade.
It catches only OrderNotCancellableException. A QueryException from a failed stock update passes through and stops the whole command. The transaction rolls back so nothing half-done remains, but the remaining orders wait until the next run.
Deadlocks are handled by the sorting and the attempts: 3 retry. As chapter 14 said, "even with a consistent order, deadlocks cannot be avoided entirely" — sorting alone is not enough. Three failed attempts and it passes through here.
What passes through here is a deadlock that failed three times, and failures that would give the same result if you continued, such as a dropped connection. The former sometimes succeeds on the run five minutes later.
The skipped count is printed so that an abnormally high number gets noticed. Zero is normal, and the occasional 1 or 2 is a conflict. Fifty skipped every run means the selection condition is wrong.
chunkById() processes 100 at a time so a large set does not all load into memory. It is chunkById() rather than chunk() because the loop rewrites status. chunk() advances with OFFSET, so rows that fall out of the condition after an update shift the following pages and things get missed.
Drop with('items') and it throws everywhere except production. Chapter 8 set Model::preventLazyLoading(! $this->app->isProduction()). cancelStale() touches $order->items, so without eager loading, LazyLoadingViolationException flies on the first order. It is the shape of working only in production and failing locally and in tests, so you notice when you write the test.
What this criterion cannot catch
Even with these conditions, a path that cancels a paid order remains.
Whether a payments row exists means not "was it paid?" but "did RecordPayment finish?" As chapter 17 showed, jobs fail. Exhaust the attempts and it lands in failed_jobs, and left there, the payments row never appears. No amount of extending the grace period reaches it.
The same happens when the worker is simply stopped. After the payment webhook queues the job, if the worker is down longer than the grace period, no payments row appears. The batch judges it unpaid, cancels it, and returns the stock. When the worker comes back, that job runs.
At that point firstOrCreate behaves as a first creation, so wasRecentlyCreated is true. The status is protected by the Pending guard, but the warehouse notification and receipt generation both run. The same goes for requeueing jobs from failed_jobs with queue:retry all.
Information inside the application alone cannot decide this state, because there is no way to know "the payment provider has a record and we do not." Making it certain requires a reconciliation process against the provider's records.
Reconciliation matches the provider's transaction records against your own payments table and surfaces the transactions that exist on only one side. To match them you need the transaction ID the provider issues, stored in payments. The payments from chapter 17 holds only the webhook event ID (provider_event_id), so reconciliation starts by adding that column. Two kinds of discrepancy come out.
- Present at the provider, absent here: a dropped webhook, or a job that fell into
failed_jobs. This is the gap this section has been describing - Present here, absent at the provider: a double record, or data from another environment mixed in
The first kind is the one you go and fix. Deleting the second kind mechanically loses a legitimate payment record whenever the records you fetched happen to be incomplete. Report the count and let a person look at it.
Reconciliation is cleanup after this command. It will almost never catch up with a side that cancels after 30 minutes, so what it finds is "an order we cancelled and restocked turned out to have a payment." Whether to restore the order or refund it is decided separately from reconciliation.
This series does not implement it. The counterpart you reconcile against differs by payment provider, and neither the format of the transaction records nor the way you fetch them can be made common.
Instead we state a premise. This processing assumes failed_jobs is not piling up. Chapter 17 covered detecting failures and the next chapter covers designing notifications. If you run auto-cancellation in production, those two come first.
Because a payments row excludes an order from the set, a refunded order stays Pending forever. So does a partial payment.
Both come from judging "was this paid?" solely by whether a payments row exists. To handle refunds, give payments a way to distinguish payments from refunds and judge by the per-order balance (total payments minus total refunds). Partial payments fall out of the same calculation. This series does not cover refunds, so the judgment stays on row existence.
The same processing running twice
The command in the previous section processes 30 minutes' worth of orders. As the set grows the run time grows, and the next run can start before the previous one finishes.
The scheduler has three controls.
Skip if the previous run is still going
Schedule::command('orders:cancel-stale')->everyFiveMinutes()->withoutOverlapping();
It takes a lock before running and exits doing nothing if it cannot. The lock's expiry defaults to 24 hours. Pass an argument to specify it in minutes.
An expiry is needed because a killed process leaves the lock behind. Without one, a single abnormal exit means the command never runs again. The 24-hour default assumes "someone notices within the day," which is far too long for something running every five minutes. Stating a value based on your estimated run time plus margin is safer.
Schedule::command('orders:cancel-stale')->everyFiveMinutes()->withoutOverlapping(10);
Chapter 17 covered WithoutOverlapping as job middleware. The names match; the mechanisms differ.
The job version targets individual queued jobs and you choose the key yourself (per order, say). The scheduler version works per schedule definition, with the key derived automatically from the command name. The default lock expiry differs too.
Run it on one server only
Grow to several application servers and cron runs on all of them. The same command starts once per server at the same moment.
Schedule::command('orders:cancel-stale')->everyFiveMinutes()->onOneServer();
It uses the cache to let only the first server through. That requires database, memcached, dynamodb, or redis as the cache driver, and every server has to look at the same cache. With file or array each server gets its own lock and nothing is prevented.
When scheduling a closure directly, it needs a name or the lock key cannot be determined.
Schedule::call(fn () => Cache::forget('stats'))
->name('forget-stats')
->daily()
->onOneServer();
Run them in parallel
runInBackground() starts several commands at once without queueing them behind each other. It works only with command and exec.
We do not add it to this chapter's commands. Their run times are short, the gain from parallelism is small, and it would stop the next section's tests from observing side effects synchronously.
Noticing failures
When a command fails, by default nobody notices.
The reason is that one cron line.
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
>> /dev/null 2>&1 discards all output. Standard output and standard error both go to /dev/null, so neither what you wrote with $this->error() nor a PHP fatal error survives.
That line is exactly what Laravel's official documentation shows. It discards output because otherwise cron tries to send mail. Getting the result of every minute's run by email is not practical.
Instead, pick up only the output you need, individually.
Schedule::command('stock:check-negative')
->hourly()
->emailOutputOnFailure(config('services.ops.mail'));
Only when the exit code is non-zero is the output emailed. Returning self::FAILURE from CheckNegativeStock when it finds negative stock was to feed this branch.
That command also sends a notification, so two messages reach the same address. The notification has counts and a breakdown; emailOutputOnFailure carries the command's raw output. Drop one if the other suffices. Both are shown to demonstrate that failures can be caught even from a command with no notification mechanism of its own.
You can also write to a file.
Schedule::command('images:prune-orphans')
->dailyAt('03:10')
->appendOutputTo(storage_path('logs/prune-orphans.log'));
sendOutputTo overwrites each time; appendOutputTo appends. Both work only with command and exec.
Both email and files assume "someone goes and looks." How to move toward sending to a monitoring service or pushing to chat is covered with log design in the next chapter.
Gathering everything registered so far
This chapter added four things to routes/console.php. Since they were shown as fragments alongside each explanation, here is the final form in one place.
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('sanctum:prune-expired --hours=24')->daily();
Schedule::command('stock:check-negative')
->hourly()
->emailOutputOnFailure(config('services.ops.mail'));
Schedule::command('images:prune-orphans')
->dailyAt('03:10')
->appendOutputTo(storage_path('logs/prune-orphans.log'));
Schedule::command('orders:cancel-stale')
->everyFiveMinutes()
->withoutOverlapping(10)
->onOneServer();
orders:cancel-stale was written three times in the previous section, to explain the controls one at a time. There is only one registration. Copy all three and you get two definitions without withoutOverlapping, producing exactly the duplication that section set out to prevent.
Tests
Split the tests in two layers. That a command works correctly and that the command is on the schedule are different problems.
The command's behavior
Use the travelTo() promised in chapter 10.
use App\Enums\OrderStatus;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('an unpaid order older than 30 minutes is cancelled and its stock returned', function () {
$stock = Stock::factory()->create(['quantity' => 8]);
travelTo(now()->subMinutes(40));
$order = Order::factory()->create(['status' => OrderStatus::Pending]);
$order->items()->create([
'product_id' => $stock->product_id,
'quantity' => 2,
'unit_price' => 1000,
]);
travelBack();
$this->artisan('orders:cancel-stale')->assertSuccessful();
expect($order->fresh()->status)->toBe(OrderStatus::Cancelled);
expect($stock->fresh()->quantity)->toBe(10);
});
test('a paid order is not cancelled', function () {
travelTo(now()->subMinutes(40));
$order = Order::factory()->create(['status' => OrderStatus::Pending]);
Payment::create([
'order_id' => $order->id,
'provider_event_id' => 'evt_12345',
'amount' => 3600,
]);
travelBack();
$this->artisan('orders:cancel-stale')->assertSuccessful();
expect($order->fresh()->status)->toBe(OrderStatus::Pending);
});
travelTo() moves into the past before the order is created. created_at takes the time at save, so creating with the clock rewound gives a 40-minute-old order. travelBack() returns to now before running the command.
The second test is the substance. It pins down that a payments row excludes the order from the set.
But what that test guards is the combined result of the selection condition and the UPDATE condition. Remove either one and the order stays Pending because the other remains, so the test passes. That the two conditions close different gaps is not readable from the test.
With one line, the sort order when returning stock does not show in the result either. Ordering matters when several lines are processed together, which a single-line test cannot see.
The wiring to the schedule
Chapter 19 said "give every piece of wiring you add a test that fails when it is removed." Registration on the schedule is wiring.
Running schedule:run is not an option. Executing Schedule::command() launches php artisan in a separate process via Symfony's Process2. The child process sees neither the transaction RefreshDatabase holds open nor the disk Storage::fake() swapped in. None of the test state reaches it, so side effects cannot be observed.
Instead, look at the list of registered commands.
use Illuminate\Console\Scheduling\Schedule;
test('the scheduled tasks are registered', function () {
$commands = collect(app(Schedule::class)->events())
->map(fn ($event) => $event->command)
->filter(); // a Schedule::call() closure has a null command
foreach ([
'sanctum:prune-expired',
'stock:check-negative',
'images:prune-orphans',
'orders:cancel-stale',
] as $expected) {
expect($commands->contains(
fn (string $command) => str_contains($command, $expected)
))->toBeTrue();
}
});
Delete a line from routes/console.php and this test fails.
There are things this test does not see. It checks only for the command name string, so mistyping daily() as everyMinute() still passes. Delete the command class and it passes as long as the schedule registration remains. Checking the frequency is more reliably done by eye with schedule:list.
Whether the registrations in routes/console.php land in the container during a test run depends on the environment. Print the count from app(Schedule::class)->events() first, confirm it holds the number you expect, and then write the assert.
With zero entries, this test always fails, because the foreach iterates over the array of expected command names rather than the contents of events(). When it fails, first separate "the registration was deleted" from "the test cannot see the registration."
What matters in production
Forget to register cron and nothing happens. Nothing errors. Everything in routes/console.php simply never runs. It is the second thing to put in your deployment procedure, after chapter 19's storage:link. Keep it as a checklist item for standing up a new server.
Confirm by the results of the work, not by schedule:run's execution log. There is no direct way to know the scheduler is running, so look at "are expired tokens decreasing?" and "are orphaned files not growing without bound?"
The time zone follows timezone in config/app.php. daily() means midnight in the application's time zone. With the server clock in UTC and the application in Asia/Tokyo, cron fires each minute in UTC and Laravel decides in JST. Drift comes from changing only one of them.
You cannot notice that schedule:run has stopped. Cron stopping, or the process failing to start, means nothing happens, quietly. If your operations depend on scheduled work, you need to record "the last successful run" somewhere and be told when it gets old.
Add onOneServer() before you scale out. Notice after scaling and you are left investigating what the duplicate runs did in the meantime. Of this chapter's four, the three deletions give the same result when run twice, but cancellation moves stock, so its effects stick.
Summary
- Schedules go in
routes/console.phpand cron gets oneschedule:runline. The reason is that the change stays as code and passes review - Use
schedule:worklocally andschedule:listto check registrations. Confirm by eye that the frequency you meant is the frequency you got - With a ready-made command, the scheduler's job is only "when to run it" (
sanctum:prune-expired) - A custom command's exit code feeds the
emailOutputOnFailurebranch. Returnself::FAILUREfor an anomaly you want surfaced - Look for orphaned files from the disk side. Choosing random names cost you the ability to work backwards from the database
- Give freshly written files a grace period, so you do not delete a file in the middle of normal processing
statusbeingPendingdoes not mean unpaid. Nothing in the series wroteConfirmed, so paid orders stayedPending- The payment-side transition also puts the condition in the WHERE. Read before writing and the batch's uncommitted update is invisible, reverting a cancelled order to
Confirmed - Put what changes between selection and update into the UPDATE's WHERE.
statusandpaymentschange in different ways, so both are conditions - The stock-returning side sorts in the same order as order confirmation. Even so it is not fully avoidable, so
attempts: 3goes on, as in chapter 14 - Return stock only when the affected count is 1.
incrementhas no condition preventing over-increase, so narrow it to once on the state transition side - A single-row conflict is a conditional UPDATE; a multi-row unit is
DB::transaction(). Touching one order row and N stock rows needs both; neither substitutes for the other - Catch the exception inside the loop so one conflict does not stop the other 99. Print the skipped count so an anomaly gets noticed
chunkById(), notchunk(). The loop rewrites the condition column, so OFFSET misses rows- Drop
with('items')and it fails everywhere except production. Chapter 8'spreventLazyLoadingturns lazy loading into an exception withoutOverlapping()'s lock expiry defaults to 24 hours. State it explicitly for short-interval workonOneServer()assumes a shared cache. Withfileorarrayit prevents nothing- Cron's
>> /dev/null 2>&1discards output. Pick up the output you need, individually Schedule::command()runs in a separate process, so side effects cannot be tested throughschedule:run. Pin the wiring down by looking at the registration list- A
paymentsrow means "did the job finish?" not "was it paid?" A payment stuck infailed_jobscannot be judged
What to read next
The next chapter, Logging, monitoring, and deployment — being able to trace what happened, starts with where failures go, promised twice in this chapter. Chapter 9's error_id, chapter 11's masking of sensitive data, chapter 13's recording of 429s, and chapter 17's queue:restart all come together into one procedure there. The cron line added in this chapter is handled as part of the deployment procedure too.
Exercises
The following schedule definition has problems. Point them out
Schedule::call(function () {
Order::where('status', OrderStatus::Pending)
->where('created_at', '<', now()->subMinutes(30))
->update(['status' => OrderStatus::Cancelled]);
})->everyMinute()->onOneServer();
Sample answer
Stock is not returned. It only rewrites the status, so reserved stock is never released. Orders pile up as cancelled while the goods remain unsellable.
It does not look at payments. As the body covered, a paid order still has status as Pending. This definition cancels paid orders after 30 minutes.
onOneServer() has no name. When scheduling a closure, name() is required to build the lock key. Without it, onOneServer() does nothing.
everyMinute() is excessive. Running a job with a 30-minute grace period every minute only picks up "orders that just crossed 30 minutes." Every five minutes caps the delay at five minutes.
Logic lives in routes/console.php. In this form it cannot be called from a test. As chapter 6 settled, the processing goes in a service class and the command calls it.
You get a report that "we ran the orphaned file sweep in production and images currently on display disappeared." What could have happened?
Sample answer
The command compares the values in products.image_path against filenames on the disk. Any state where those two do not line up makes referenced files look orphaned.
The disk specification is the prime suspect. The implementation in the body states Storage::disk('public'), but omit it anywhere and it looks at the default disk (local). As chapter 19 covered, local and public are different locations. Enumerating one disk and comparing against paths from public classifies every file as unreferenced.
The path shape has to match too. When image_path holds products/abc.jpg, Storage::files('products') also returns products/abc.jpg. Insert a basename(), or prepend a /, and the strings stop matching. Everything on the non-matching side becomes an orphan.
The order of checks starts with counting rather than deleting. Comment out the delete line and print the count, and an order-of-magnitude difference from your expectation is immediately obvious. Let a command that touches the disk run once as a dry run before it goes to production.
Note that this accident cannot be undone. Storage::delete() does not go through any trash. You restore from a backup or re-upload the images.