Task Scheduling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The scheduler replaces
a crontab full of entries with one entry and a schedule written in PHP and kept in Git. Cron runs
php artisan schedule:run every minute. That command checks which tasks are due and runs them. Bookshelf sends
due-date reminders every morning, syncs the catalogue every minute and prunes old data every night. How cron compares
with job schedulers such as Quartz, and what changes on a cluster, is on
Standard Scheduling vs Quartz.
The one cron entry
# crontab -e for the user that owns the application
* * * * * cd /srv/bookshelf/current && php artisan schedule:run >> /dev/null 2>&1
Source: Laravel — Running the Scheduler
Locally, php artisan schedule:work runs the scheduler in the foreground and calls schedule:run every minute (and
at sub-minute boundaries when tasks need it). Cron syntax itself is covered on
Cron Expressions.
Defining the schedule
Tasks are defined in routes/console.php with the Schedule facade: Schedule::command() for Artisan commands,
Schedule::job() to queue a job, Schedule::call() for a closure and Schedule::exec() for a shell command.
Bookshelf’s schedule:
<?php
// routes/console.php
use App\Events\LoanDueSoon;
use App\Models\Loan;
use Illuminate\Support\Facades\{Artisan, Cache, Log, Schedule};
Artisan::command('loans:remind-due', function () {
$loans = Loan::where('status', 'active')->whereBetween('due_at', [now(), now()->plus(days: 2)])->get();
$loans->each(fn (Loan $loan) => LoanDueSoon::dispatch($loan));
$this->info("{$loans->count()} reminders queued");
})->purpose('Remind members of loans due within two days');
Artisan::command('catalogue:sync-all', function () {
sleep(5); // stands in for a long sync
$this->info('synced');
})->purpose('Sync every book with the catalogue');
Schedule::command('loans:remind-due')
->dailyAt('08:00')
->timezone('Europe/Madrid')
->onOneServer()
->onFailure(fn () => Log::error('due-date reminders failed'));
Schedule::command('catalogue:sync-all')->everyMinute()->withoutOverlapping(10)->runInBackground()
->appendOutputTo(storage_path('logs/schedule-sync.log'));
Schedule::call(fn () => Cache::put('scheduler:heartbeat', now()->toIso8601String()))->everyFifteenSeconds()->name('heartbeat');
Schedule::daily()->onOneServer()->group(function () {
Schedule::command('model:prune');
Schedule::command('queue:prune-batches --hours=48');
Schedule::command('sanctum:prune-expired --hours=24');
});
Source: Laravel — Defining Schedules and Schedule Groups
$ php artisan schedule:list
0 3 * * * php artisan books:import storage/app/import/books.csv --no-interaction Next Due: 4 hours from now
0 6 * * * php artisan loans:remind-due .. Next Due: 7 hours from now
* * * * * php artisan catalogue:sync-all Next Due: 46 seconds from now
* * * * * 15s heartbeat .. Next Due: 1 second from now
0 0 * * * php artisan model:prune .. Next Due: 1 hour from now
0 0 * * * php artisan queue:prune-batches --hours=48 Next Due: 1 hour from now
0 0 * * * php artisan sanctum:prune-expired --hours=24 Next Due: 1 hour from now
$ php artisan schedule:list --timezone=Europe/Madrid | grep remind
0 8 * * * php artisan loans:remind-due .. Next Due: 7 hours from now
Source: Laravel — Timezones
schedule:list shows expressions in the application’s time zone (UTC), so the 08:00 Madrid reminder appears as
0 6, the summer offset. The scheduler converts each time it runs, so the task moves to 0 7 UTC after the switch
to winter time and still runs at 08:00 in Madrid. The documentation warns that on the nights the clocks change, a
task scheduled in a local time zone can run twice or not at all, and recommends avoiding time zone scheduling when
possible. A task that must run exactly once a day is safer in UTC, with the local time worked out in the task.
Overlaps and several servers
Bookshelf started two schedule:run processes 1.5 seconds apart in the same minute. That is what happens when two
servers have the cron entry, or when one run is still going as the next minute starts:
run 1: 2026-10-05 22:22:01 Running ['artisan' catalogue:sync-all] in background 5.46ms DONE
run 1: ⇂ ('/usr/bin/php8.3' 'artisan' catalogue:sync-all >> 'storage/logs/schedule-sync.log' 2>&1 ; '/usr/bin/php8.3' 'artisan' schedule:finish "framework/schedule-4315fc1f…" "$?") > '/dev/null' 2>&1 &
run 1: 2026-10-05 22:22:01 Running [heartbeat] .. 1.92ms DONE
run 1: 2026-10-05 22:22:16 Running [heartbeat] .. 1.57ms DONE
run 1: 2026-10-05 22:22:31 Running [heartbeat] .. 1.47ms DONE
run 1: 2026-10-05 22:22:46 Running [heartbeat] .. 2.25ms DONE
run 1: finished 22:23:00
run 2: 2026-10-05 22:22:02 Running [heartbeat] .. 2.36ms DONE
run 2: 2026-10-05 22:22:18 Running [heartbeat] .. 2.37ms DONE
run 2: 2026-10-05 22:22:33 Running [heartbeat] .. 2.14ms DONE
run 2: 2026-10-05 22:22:48 Running [heartbeat] .. 2.37ms DONE
run 2: finished 22:23:00
The run shows four behaviours:
-
withoutOverlapping(10)kept the second run out of the sync. The first run took a cache lock (with a 10-minute expiry, in case the process dies), so run 2 skipped the task.storage/logs/schedule-sync.logholds onesynced. -
The heartbeat ran eight times instead of four. It has neither
withoutOverlappingnoronOneServer, so both schedulers ran it. On two web servers that each have the cron entry, every unguarded task runs twice. That is harmless for a heartbeat and wrong for reminders or invoices, which is why those useonOneServer(). -
onOneServer()andwithoutOverlapping()are cache locks. They work across servers only when the default cache store is shared by all of them (Redis, Memcached, database or DynamoDB). With thefileorarraystore, each server takes its own lock. -
Sub-minute tasks keep
schedule:runalive for the rest of the minute. Both processes ran until 22:23:00, firing the heartbeat every 15 seconds.runInBackground()let the five-second sync run beside them instead of delaying the next heartbeat. During a deploy,php artisan schedule:interruptstops a run that is in progress, so it does not keep using the old code until the minute ends.
Exactly-once execution across a cluster, misfire handling and persistent job stores are beyond what the Laravel scheduler offers. Those are the cases on Clustering and Misfires where a job scheduler fits better.
Heavy work belongs in the queue
The scheduler runs tasks one after another inside schedule:run, apart from runInBackground() ones. A slow task
delays the ones after it and holds a process on the cron host. Schedule::job(new PruneStaleCovers) dispatches a job
instead, so the queue workers do the work, with retries and Horizon visibility. loans:remind-due follows this
pattern: the command only selects loans and dispatches broadcast events, and the delivery happens in workers
(Queues and Jobs).
Constraints, hooks and output
-
Constraints:
weekdays(),between('08:00', '20:00'),when(fn () => …),skip(…), andenvironments(['production']). Tasks do not run in maintenance mode unless they useevenInMaintenanceMode(). -
Pausing:
php artisan schedule:pausestops all tasks untilschedule:continuewithout a deploy, for example during a database migration.evenWhenPaused()exempts a task. -
Hooks:
before,after,onSuccessandonFailure. A failure means a non-zero exit code for commands and an exception for closures. -
Output:
sendOutputTo,appendOutputToandemailOutputOnFailure. Without them, the output is discarded. -
Pings:
pingBefore($url)andthenPing($url)orpingOnFailure($url)call a dead man’s switch service such as Healthchecks.io or Oh Dear. A monitor that expects a ping and raises an alert when none comes catches the failure that hooks cannot report: the cron entry missing after a server rebuild.
php artisan schedule:test lists the tasks and runs the one chosen, now, which is handy after a change.
Testing
The command is tested like any command. The schedule itself is plain data on the Schedule object, so a test can
check that a task exists with the right expression, time zone and lock:
<?php
// tests/Feature/ScheduleTest.php
use App\Events\LoanDueSoon;
use App\Models\Loan;
use Illuminate\Console\Scheduling\{Event as ScheduledEvent, Schedule};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
uses(RefreshDatabase::class);
test('reminders go out for loans due within two days', function () {
Event::fake([LoanDueSoon::class]);
Loan::factory()->create(['status' => 'active', 'due_at' => now()->plus(days: 1)]);
Loan::factory()->create(['status' => 'active', 'due_at' => now()->plus(days: 10)]);
$this->artisan('loans:remind-due')->expectsOutput('1 reminders queued')->assertSuccessful();
Event::assertDispatchedTimes(LoanDueSoon::class, 1);
});
test('the reminder runs at eight in Madrid on one server', function () {
$task = collect(app(Schedule::class)->events())
->first(fn (ScheduledEvent $event) => str_contains($event->command ?? '', 'loans:remind-due'));
expect($task->expression)->toBe('0 8 * * *')
->and($task->timezone)->toBe('Europe/Madrid')
->and($task->onOneServer)->toBeTrue();
});
Source: Laravel — Console Tests
Both pass. The second test catches a refactor that drops onOneServer(), which no functional test would notice
until a second server is added.
Where older books and tutorials are outdated
-
protected function schedule(Schedule $schedule)inapp/Console/Kernel.php(Stauffer ch. 16) moved toroutes/console.phpwith theSchedulefacade in Laravel 11, when the console kernel was removed. AwithSchedule()callback inbootstrap/app.phpis the other option. -
One crontab line per task is replaced by the single
schedule:runentry. -
everyMinute()as the finest frequency changed in Laravel 10.15, which addedeverySecond()toeveryThirtySeconds(). -
Long work inside the scheduled closure is better dispatched to the queue with
Schedule::job().