What Laravel 13.35 changed in the scheduler
Laravel 13.35.0 was tagged on 6 October 2026. If you run schedule:run on more than one box, two of its changes are yours.
Schedule::alwaysOnOneServer() (pull request #61789) applies onOneServer() to every task with a single call. Schedule::hasBeenInterruptedSince() (pull request #61764) lets a long background command notice that a deploy ran schedule:interrupt.
Both are good. They also hide a bit more of what your scheduler did.
How alwaysOnOneServer works
Until now you added onOneServer() task by task, or wrapped the schedule in a group. Forget one and scale out, and you get what the pull request author described: "why are we sending 50 of these?" Now it is one line in a service provider:
use Illuminate\Support\Facades\Schedule;
public function boot(): void
{
Schedule::alwaysOnOneServer();
Schedule::useCache('redis');
}
It is off by default. Every server has to talk to the same cache store, so Redis, Memcached, DynamoDB or the database driver. With the file or array driver each server has its own store and each one wins the lock.
The lock is per task and per minute
The lock lives in CacheSchedulingMutex. Its key is the task's mutex name plus the scheduled hour and minute, with a TTL of 3600 seconds. Whoever takes the 17:00 lock first runs the task.
The winner keeps the lock, success or not
Nothing releases the lock when the task ends. That is on purpose: it stops a slow server from running the task a second later.
It also means each slot runs once or never. Say the winning server gets OOM-killed halfway through. No other server retries, because they all logged that slot as handled elsewhere.
Unnamed closures still run everywhere
The global switch skips closure tasks that have no name. No exception, no warning. They just keep running on every server:
// Still runs on every server
Schedule::call(fn () => Report::sendDaily())->daily();
// Runs once
Schedule::call(fn () => Report::sendDaily())
->name('send-daily-report')
->daily();
Since this release schedule:list --json shows an onOneServer field per task, so one command finds them.
The cache now sits on every task's path
schedule:run already reads the default cache each run to check its pause flag. The global lock adds a write for every due task, on whatever store useCache() points at.
That write happens in the main loop, outside the try/catch around each task. If Redis is restarting and the lock call throws, schedule:run dies right there. Tasks earlier in the list ran. The ones after it don't start, and next minute has its own due list. A daily task that lost its slot waits until tomorrow.
Deploys and long background tasks
What schedule:interrupt used to reach
schedule:interrupt was built for sub-minute tasks, where schedule:run keeps looping on the old release until the minute ends. A command started with runInBackground() lives in its own process and never saw the signal.
Polling with hasBeenInterruptedSince
schedule:interrupt now stores a millisecond timestamp, the way queue:restart does for workers. A long command notes its start time and checks between batches:
public function handle(Schedule $schedule): int
{
$startedAt = now();
foreach (Product::lazyById(500) as $product) {
if ($schedule->hasBeenInterruptedSince($startedAt)) {
return self::SUCCESS;
}
$this->syncProduct($product);
}
return self::SUCCESS;
}
Look at that return value. The upstream example exits 0 when interrupted, so your success hooks fire for a run that synced a third of the catalogue. The next run is meant to finish the job. If that one gets interrupted too, the data stays stale while every run reports success.
Return a non-zero code on interrupt.
The interrupt lives in the default cache store
The timestamp goes to the default store, not the useCache() store. With a file default and a deploy script that runs on one server, the other servers never see it. And with withoutInterruptionPolling() set, hasBeenInterruptedSince() always returns false.
What the scheduler will not tell you
Losing servers log to /dev/null
The skip message ("Skipping [...] because the command already ran on another server") is plain command output. The cron entry from the docs throws that away:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
A lost lock doesn't dispatch ScheduledTaskSkipped either.
Background failures skip ScheduledTaskFailed
With runInBackground(), schedule:run returns before the command ends. Laravel appends schedule:finish with the exit code, and that fires ScheduledBackgroundTaskFinished. A listener that only handles ScheduledTaskFailed never hears about a failed background task.
Check in from the server that ran it
One process knows the task ran: the winner, once the work is done. Send the signal from there:
Schedule::command('inventory:sync')
->hourly()
->runInBackground()
->pingOnSuccess('https://cronguard.app/api/ping/inventory-sync');
pingOnSuccess only fires on exit code 0. Give each task its own check, with a window a bit longer than its interval. A dead winner and an unnamed closure that ran nowhere look identical from outside. No ping arrives, and that silence is your alert.
Frequently asked questions about Laravel's alwaysOnOneServer
What does Schedule::alwaysOnOneServer() do? It applies onOneServer to every task in the Laravel schedule with one call in a service provider. It shipped in Laravel 13.35.0 on 6 October 2026 and is off by default. All servers must share one cache store, such as Redis, Memcached, DynamoDB or the database driver.
Why do some tasks still run on every server after enabling it? The global switch skips closure tasks that have no name, without any error. Give each scheduled closure a name.
What happens if the server that won the lock crashes mid-task? Nothing reruns it. The lock is keyed to the task and the scheduled minute, and the other servers have already skipped that slot. The task waits for its next scheduled time.
Does hasBeenInterruptedSince make deploys safe for long scheduled commands? It lets a background command stop at a safe point after a deploy runs schedule:interrupt. The upstream example returns success when interrupted, so success hooks and pings fire for partial work. Return a non-zero exit code on interrupt.
How do I know a Laravel scheduled task actually ran on one of my servers? Losing servers only print a skip line, which the standard cron entry discards, and background failures do not fire ScheduledTaskFailed. Have the task check in with pingOnSuccess after the work is done, and alert when that check-in does not arrive in time.
Further reading
- Preventing Duplicate Cron Jobs When You Scale to Multiple Servers
- When a Scheduled Job Runs Long: Overlap, Skip, or Replace
- Dead Man's Switch Monitoring: The Only Reliable Way to Watch Cron Jobs
Conclusion: If you run the scheduler on several servers, turn alwaysOnOneServer on. One line kills a whole class of duplicate runs. The price is that a slot now runs once or not at all, and only the winning server knows which. So the proof has to come from that server, after the work, and an interrupted command must not get to call itself a success.
Sources: Laravel v13.35.0 release notes, laravel/framework pull request 61789, laravel/framework pull request 61764, Laravel task scheduling documentation, CacheSchedulingMutex source.