Every Laravel application picks up scheduled work eventually: nightly invoices, hourly imports, a report that goes out on the first of the month, a cleanup job nobody remembers writing. The schedule is usually the least-tested, least-monitored part of a production system — and the part that fails silently. A queue worker crash shows up in your error tracker. A cron entry that stopped firing three weeks ago shows up in a customer complaint.
This tutorial covers the parts of Laravel's scheduler that matter once real money depends on it: a single correct cron entry, overlap and multi-server locking, timezone-safe schedules, pushing work onto queues instead of blocking the scheduler, background execution, failure notifications, health-check pings, and how to test a schedule without waiting a day for it to run.
Examples target Laravel 11 through 13, where the schedule is defined in routes/console.php or via withSchedule() in bootstrap/app.php. If you are still on Laravel 10 or earlier, the same calls live in App\Console\Kernel::schedule() and everything else applies unchanged.
One cron entry, and only one
Laravel does not install a cron entry per task. It installs exactly one entry that runs the scheduler every minute; the scheduler then decides which tasks are due:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Three things people get wrong here:
- The user. Put the entry in the crontab of the same user that owns your deploy (
www-data,deploy, whatever your setup uses). Running it asrootwill create cache and log files that your PHP-FPM user cannot write later. - The PHP binary. Cron has almost no
PATH. If your server has several PHP versions, use an absolute path:/usr/bin/php8.3 artisan schedule:run. - Swallowing output.
>> /dev/null 2>&1is fine because Laravel reports failures itself (we wire that up below). If you have not wired anything up yet, log it instead:>> /var/www/app/storage/logs/schedule.log 2>&1.
On a containerised platform, run the scheduler as its own long-lived process rather than a cron daemon inside the app container:
php artisan schedule:work
schedule:work stays in the foreground and invokes the scheduler every minute, which is exactly what a supervised process or a Kubernetes Deployment wants. It is also the command to use locally — you get the real scheduler behaviour without touching your machine's crontab.
Defining tasks
use Illuminate\Support\Facades\Schedule;
use App\Jobs\SendDunningEmails;
use App\Console\Commands\ImportInventory;
Schedule::command(ImportInventory::class, ['--source=ftp'])->hourly();
Schedule::job(new SendDunningEmails)->dailyAt('07:15');
Schedule::call(function () {
DB::table('sessions')->where('last_activity', '<', now()->subWeek()->getTimestamp())->delete();
})->daily()->name('prune-sessions');
Prefer Schedule::command() and Schedule::job() over Schedule::call(). Closures cannot be run by hand when you are debugging at 2am, they cannot be tested in isolation, and — as of Laravel 11 — a closure without an explicit ->name() cannot be given an overlap lock. A command is a class you can invoke, test, and read in a log line.
Use Schedule::job() when the work belongs on a queue and you want the scheduler to do nothing but dispatch it. That is the single most important structural decision in this whole article, so it gets its own section.
The scheduler is not a worker
schedule:run is a dispatcher. If a task takes eleven minutes, and you have not run it in the background, the next invocation of schedule:run is a separate process — so you now have overlapping work and a process that outlives its minute. Long synchronous tasks in the scheduler cause missed runs, memory bloat, and deploys that hang.
Two escape hatches, in order of preference:
// Best: the scheduler dispatches, a queue worker does the work.
Schedule::job(new RebuildSearchIndex, 'indexing')->hourly();
// Acceptable for short shell-ish work: fork it so schedule:run returns immediately.
Schedule::command('reports:nightly')->dailyAt('02:00')->runInBackground();
runInBackground() forks the command with &, meaning schedule:run no longer waits for it. The trade-off is that the task's exit code is collected out of band, so keep onFailure() handlers (below) rather than relying on cron mail. Anything that touches an external API, sends email, or processes more than a few hundred rows belongs in a queued job, not in the scheduler process.
Stop tasks from stepping on each other
Two distinct problems, two distinct guards.
Same task overlapping itself — the previous run has not finished when the next becomes due:
Schedule::command('import:inventory')
->everyFiveMinutes()
->withoutOverlapping(30); // lock expires after 30 minutes
withoutOverlapping() takes an atomic cache lock keyed on the task. Always pass an expiry. The default is 24 hours, so a task whose process is killed with SIGKILL (out-of-memory, a node drain, a kill -9 during deploy) leaves a lock behind and your import quietly stops running for a full day. Set the expiry to a few times the task's normal duration and no longer.
This needs a shared, atomic cache store. Redis, Memcached, DynamoDB and the database store all support atomic locks; the file and array stores do not lock across machines. If your app cache is file, point the scheduler at something else:
// bootstrap/app.php
->withSchedule(function (Schedule $schedule) {
$schedule->useCache('redis');
// ... tasks
})
Task running on every server — you scaled the app to three web nodes, every node has the cron entry, and your customers get three copies of every invoice:
Schedule::command('invoices:send')->dailyAt('06:00')->onOneServer();
onOneServer() uses the same lock mechanism to elect a single runner per due minute. It requires a cache store shared between servers — a Redis instance all nodes reach, not a per-node file cache. If you combine it with runInBackground(), give the task an explicit name so the lock key is stable:
Schedule::command('reports:nightly')
->dailyAt('02:00')
->name('nightly-reports')
->onOneServer()
->runInBackground();
The alternative architecture — and the one worth aiming for — is a dedicated scheduler container or instance where exactly one process runs schedule:work, and the web nodes have no cron entry at all. Then onOneServer() is belt-and-braces rather than load-bearing.
Timezones, DST and month boundaries
Cron expressions are evaluated in your application's timezone (config/app.php), which in a well-behaved app is UTC. Business rules, however, are rarely in UTC: "send the daily summary at 8am local time" means something different in Denver in January and in July.
Schedule::command('summary:daily')
->dailyAt('08:00')
->timezone('America/Denver');
Set it per task, or globally via a scheduleTimezone() method, but be aware of what daylight saving does to it: when clocks jump forward, a task scheduled for 02:30 local does not run that day; when clocks fall back, a task in the repeated hour can run twice. Keep anything financial or idempotency-sensitive in UTC and convert for display, or schedule it outside 01:00–03:00 local.
Month-end is the other classic trap. ->monthlyOn(31, '03:00') never fires in February. Use the last-day helper, or schedule daily and guard:
Schedule::command('payroll:close')->lastDayOfMonth('03:00');
Schedule::command('reports:quarterly')
->monthlyOn(1, '04:00')
->when(fn () => in_array(now()->month, [1, 4, 7, 10]));
when() and skip() are also how you keep staging quiet without forking your schedule file:
Schedule::command('emails:campaign')
->hourly()
->environments(['production'])
->skip(fn () => Cache::get('maintenance-mode'));
Hooks: know when it broke
The scheduler gives you lifecycle hooks on every task. Wire failures into whatever already pages you:
use Illuminate\Support\Facades\Log;
Schedule::command('import:inventory')
->hourly()
->withoutOverlapping(20)
->before(fn () => Log::info('inventory import starting'))
->onSuccess(function () {
Log::info('inventory import ok');
})
->onFailure(function () {
report(new \RuntimeException('Inventory import failed'));
})
->emailOutputOnFailure('ops@example.com');
onFailure() fires when the command exits non-zero — which means your Artisan commands must actually return a failure code. A command that catches an exception, logs it, and returns nothing returns 0, and the scheduler considers it a success:
public function handle(): int
{
try {
$this->importer->run();
} catch (Throwable $e) {
report($e);
return self::FAILURE;
}
return self::SUCCESS;
}
For queued work dispatched with Schedule::job(), the hooks only tell you the dispatch succeeded. Failure handling for the work itself belongs in the job's failed() method and your queue's failed_jobs monitoring.
Dead-man switches beat dashboards
The failure mode that costs the most is not a task that errors — it is a task that never ran. A crashed scheduler process, a cron entry lost in a server rebuild, or a lock stuck from a killed process all produce silence, and silence never triggers an alert.
The fix is a heartbeat: the task pings an external service on success, and that service alerts you when the ping does not arrive.
Schedule::command('invoices:send')
->dailyAt('06:00')
->onOneServer()
->pingOnSuccess('https://checks.example.com/ping/invoices-daily')
->pingOnFailure('https://checks.example.com/ping/invoices-daily/fail');
pingBefore(), thenPing(), pingOnSuccess() and pingOnFailure() all use the HTTP client, so they work with Healthchecks.io, Cronitor, Better Stack, Oh Dear, or your own endpoint. Configure the check window slightly wider than the task's schedule (a daily task gets a 25-hour grace period) so a slow run does not page anyone.
Add one heartbeat for the scheduler itself:
Schedule::call(fn () => null)
->everyFiveMinutes()
->name('scheduler-heartbeat')
->thenPing('https://checks.example.com/ping/scheduler-alive');
If that stops, you know the whole scheduler is down rather than one task.
Auditing what you actually have
Two commands worth knowing:
php artisan schedule:list
php artisan schedule:list --timezone=America/Denver
schedule:list prints every task, its cron expression, and the next due time — the fastest way to catch a task that is due "in 11 months" because someone wrote ->monthlyOn(31).
php artisan schedule:test
schedule:test lets you pick a scheduled task and run it immediately, in the foreground, with output. This is what you use during an incident instead of editing the cron expression to everyMinute and waiting.
Testing the schedule
Two things are worth asserting in CI: that a task is registered on the frequency you think it is, and that the command it invokes does the right thing.
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Queue;
use App\Jobs\SendDunningEmails;
it('schedules the inventory import hourly without overlap', function () {
$events = collect(app(Schedule::class)->events())
->filter(fn ($e) => str_contains($e->command ?? '', 'import:inventory'));
expect($events)->toHaveCount(1);
expect($events->first()->expression)->toBe('0 * * * *');
expect($events->first()->withoutOverlapping)->toBeTrue();
});
it('dispatches dunning emails rather than sending them inline', function () {
Queue::fake();
$this->artisan('billing:dunning')->assertSuccessful();
Queue::assertPushed(SendDunningEmails::class);
});
Then travel in time for date-sensitive logic, which is where month-end bugs actually get caught:
it('closes payroll on the last day of a short month', function () {
$this->travelTo('2026-02-28 03:00:00');
$this->artisan('payroll:close')->assertSuccessful();
expect(PayrollPeriod::latest('id')->first()->closed_at)->not->toBeNull();
});
Deploys and the schedule
Two small things that prevent a bad night:
- Drain before you swap. If a task is mid-run when your deploy replaces the release directory, it will fail against half-missing code. Dedicated scheduler processes should be stopped, then restarted after the symlink swap — the same treatment queue workers get from
php artisan queue:restart. - Maintenance mode skips the scheduler. While the app is down for
php artisan down, scheduled tasks do not run, which is usually what you want. If a task must run regardless (a status page updater, say), mark it->evenInMaintenanceMode().
A production-ready checklist
- Exactly one
schedule:runcron entry per deploy, as the deploy user, with an absolute PHP path — or one supervisedschedule:workprocess. - Tasks are commands or jobs, never anonymous closures.
- Anything slow is a queued job, or at minimum
runInBackground(). withoutOverlapping($minutes)on every repeating task, always with an explicit expiry.onOneServer()on anything that must happen once, backed by a shared Redis cache — or a single dedicated scheduler instance.- Commands return
SUCCESS/FAILUREsoonFailure()actually fires. - Timezone set deliberately per task; nothing critical scheduled in the DST window; no
monthlyOn(29..31). - A heartbeat ping on every business-critical task, plus one for the scheduler itself.
schedule:listreviewed whenever someone adds a task, and frequencies asserted in tests.
Get those nine right and the schedule stops being the part of the system you find out about from a customer.
Polish & Pixel builds and maintains Laravel applications, including the unglamorous production scaffolding — schedulers, queues, deploys and monitoring — that keeps them running. If your scheduled work is unmonitored, overlapping, or firing twice across servers, get in touch and we will help you sort it out.