Laravel Tutorials

Running Laravel's Scheduler on Multiple Servers

Published
Running Laravel's Scheduler on Multiple Servers image

A Laravel application that runs schedule:run on more than one server runs each scheduled task once per server. That is fine for a task that clears a local cache. However, for a task that sends a daily report email that's not ideal.

Laravel 13.35 adds two changes for this setup. The new Schedule::alwaysOnOneServer() method runs every task on one server, and Schedule::hasBeenInterruptedSince() lets a long-running scheduled command stop when a deploy runs schedule:interrupt.

Using onOneServer() for Every Task

Laravel has had onOneServer() for a long time. The first server to start the task takes a lock in the cache, and the other servers skip it for that run:

Schedule::command('reports:send')->daily()->onOneServer();

The problem is remembering it for every new task needs the call, and a missed one only shows up when the duplicates do. The workaround was wrapping the whole schedule in a group:

Schedule::onOneServer()->group(function () {
Schedule::command('reports:send')->daily();
Schedule::command('invoices:generate')->monthly();
// every other task...
});

Jack Bayliss contributed Schedule::alwaysOnOneServer(), which turns it on for the whole schedule from a service provider:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Schedule;
 
public function boot(): void
{
Schedule::alwaysOnOneServer();
}

The flag is applied when the schedule reads its events, so tasks defined later in routes/console.php are covered, including ones added after the call. It is off by default, and Schedule::alwaysOnOneServer(false) turns it back off.

Name Your Closure Tasks

The onOneServer() method builds its lock key from the task's name. A command task has one from its command line, but a closure task only has one if you call name(). Calling onOneServer() on an unnamed closure throws an exception, so alwaysOnOneServer() skips those closures instead. They still run on every server:

// Runs on every server, even with alwaysOnOneServer()
Schedule::call(fn () => Report::sendDaily())->daily();
 
// Runs on one server
Schedule::call(fn () => Report::sendDaily())->name('send-daily-report')->daily();

There is no warning for a skipped closure, so check closure tasks for a name() call when you turn the flag on.

A Shared Cache

The lock lives in the cache, so every server has to use the same cache store, such as Redis, Memcached, DynamoDB, or the database. With the file or array store, each server has its own lock and every server runs the task.

By default, the scheduler takes its locks in your application's default cache store, the one set by CACHE_STORE. If that store is local to each server, for example because the app uses file for its own caching, call Schedule::useCache() with the name of a shared store from config/cache.php. It goes in the same boot() method as alwaysOnOneServer():

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Schedule;
 
public function boot(): void
{
Schedule::alwaysOnOneServer();
 
// The 'redis' store from config/cache.php, shared by every server
Schedule::useCache('redis');
}

The useCache() method changes the store for the scheduler's locks only, which covers onOneServer() and withoutOverlapping(). The rest of your application keeps using its default store. If the default store is already shared, such as redis or database on every server, you can skip this call.

There is no per-task opt-out. If some tasks need to run on every server, such as one that clears files on local disk, leave alwaysOnOneServer() off and call onOneServer() on the tasks that should run once.

Stopping Long Commands During a Deploy

A deploy script might run php artisan schedule:interrupt before it switches to the new release. Before 13.35, that command set a flag in the cache until the end of the current minute, and only schedule:run checked it, to stop its loop of sub-minute tasks. A command started with runInBackground() runs in its own process and never checked the flag, so a task that ran for an hour finished on the old code or was killed partway through.

cyppe changed schedule:interrupt to store the time of the interrupt instead, the same way queue:restart stores a restart time for queue workers. A long command can now compare that time with its own start time and stop between batches:

use Illuminate\Console\Command;
use Illuminate\Console\Scheduling\Schedule;
 
class SyncInventory extends Command
{
protected $signature = 'inventory:sync';
 
public function handle(Schedule $schedule): int
{
$startedAt = now();
 
foreach (Product::lazyById(500) as $product) {
if ($schedule->hasBeenInterruptedSince($startedAt)) {
$this->info('Interrupted by a deploy, stopping.');
 
return self::SUCCESS;
}
 
$this->syncProduct($product);
}
 
return self::SUCCESS;
}
}
Schedule::command('inventory:sync')->hourly()->runInBackground();

The command needs to be able to stop at any point it checks, and the next scheduled run has to pick up the remaining work. Here, the sync can run again over every product, so stopping halfway loses nothing.

The hasBeenInterruptedSince() method returns true only for an interrupt at or after the time you pass in, so an interrupt from an earlier deploy does not stop a new run. The same check now drives schedule:run, which no longer stops for an interrupt sent earlier in the same minute.

The interrupt time is stored in the default cache store, not the store passed to useCache(), so on several servers, the default store needs to be one they all share. If you call Schedule::withoutInterruptionPolling() to save the cache lookups, hasBeenInterruptedSince() always returns false.

A deploy script that signals both the scheduler and the queue workers looks like this:

php artisan schedule:interrupt
# switch to the new release, run migrations, and so on
php artisan queue:restart

Further Reading

Jack Bayliss contributed alwaysOnOneServer() in #61789, and cyppe contributed hasBeenInterruptedSince() in #61764.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Sponsored

masteringlaravel logo
Laravel Code Review

Get expert guidance in a few days with a Laravel code review

Visit Laravel Code Review

The latest

View all →
Laravel Fake Assertions Now Accept Property Arrays image

Laravel Fake Assertions Now Accept Property Arrays

Read article
Route::query() and Model defaults() in Laravel 13.35 image

Route::query() and Model defaults() in Laravel 13.35

Read article
Laravel AI SDK 1.1 Adds Agent Skills and Cohere Chat image

Laravel AI SDK 1.1 Adds Agent Skills and Cohere Chat

Read article
VMPal: Give Your AI Agent a Whole Computer image

VMPal: Give Your AI Agent a Whole Computer

Read article
Synapse: A Dev Dashboard for Laravel AI SDK Agents image

Synapse: A Dev Dashboard for Laravel AI SDK Agents

Read article
Decide with Jev: Three Questions in One Request with the Laravel AI SDK image

Decide with Jev: Three Questions in One Request with the Laravel AI SDK

Read article