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

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');
});
$ 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

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.log holds one synced.

  • The heartbeat ran eight times instead of four. It has neither withoutOverlapping nor onOneServer, 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 use onOneServer().

  • onOneServer() and withoutOverlapping() 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 the file or array store, each server takes its own lock.

  • Sub-minute tasks keep schedule:run alive 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:interrupt stops 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(…), and environments(['production']). Tasks do not run in maintenance mode unless they use evenInMaintenanceMode().

  • Pausing: php artisan schedule:pause stops all tasks until schedule:continue without a deploy, for example during a database migration. evenWhenPaused() exempts a task.

  • Hooks: before, after, onSuccess and onFailure. A failure means a non-zero exit code for commands and an exception for closures.

  • Output: sendOutputTo, appendOutputTo and emailOutputOnFailure. Without them, the output is discarded.

  • Pings: pingBefore($url) and thenPing($url) or pingOnFailure($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();
});

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) in app/Console/Kernel.php (Stauffer ch. 16) moved to routes/console.php with the Schedule facade in Laravel 11, when the console kernel was removed. A withSchedule() callback in bootstrap/app.php is the other option.

  • One crontab line per task is replaced by the single schedule:run entry.

  • everyMinute() as the finest frequency changed in Laravel 10.15, which added everySecond() to everyThirtySeconds().

  • Long work inside the scheduled closure is better dispatched to the queue with Schedule::job().