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.phpuse 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 serverSchedule::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.phpuse 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 onphp artisan queue:restart
Further Reading
- Route::query() and Model defaults() in Laravel 13.35
- 7 Tips for Adding a Second Server to your App
- Streamlining Application Automation with Laravel's Task Scheduler
- Sub-Minute and Cron-less Scheduled Tasks in Laravel
Jack Bayliss contributed alwaysOnOneServer() in #61789, and cyppe contributed hasBeenInterruptedSince() in #61764.