Your Laravel Scheduler Is Probably Running Twice. 13.35 Finally Fixes It in One Line

How Schedule::alwaysOnOneServer() replaces per-task onOneServer() calls, and how to stop long scheduled commands cleanly during a deploy.


A customer gets the same “your daily report is ready” email three times at 08:00. The command is correct, the queue is healthy, and the logs show no errors, because nothing failed. The scheduler simply ran on three servers, each one looked at the same reports:send entry, decided independently that it was due, and sent. The author of the pull request behind this release described the moment in one line: “hold on, why are we sending 50 of these?” Laravel has had onOneServer() for years to prevent exactly this, and the reason it keeps happening anyway is that it’s opt-in per task. The one task you forget to mark is the one that emails customers twice, and the only symptom is the duplicates.

A precision before anything else, because the title says “probably”: this only applies if schedule:run is firing on more than one machine, which means multiple servers or multiple replicas each with their own cron entry. One server with one cron line has nothing to fix here. For everyone else, Laravel 13.35 (released October 6) adds Schedule::alwaysOnOneServer(), contributed by Jack Bayliss in PR #61789, which turns the behavior on for the entire schedule at once. It also adds a way for long-running scheduled commands to stop cleanly when a deploy runs schedule:interrupt, contributed by cyppe in PR #61764. The one line really is one line. It also quietly depends on three conditions that nothing will warn you about if they aren’t met, and that’s the part worth reading closely.


Why It Runs Once Per Server

Every server with a cron entry for schedule:run evaluates the full schedule independently. Each one asks “which tasks are due this minute,” gets the same answer, and runs them. There is no coordination between servers by default, because the scheduler has no way to know other servers exist.

onOneServer() adds that coordination through the cache. The first server to start a task takes a lock; every other server sees the lock and skips that task for that run.

// routes/console.php
Schedule::command('reports:send')->daily();                  // ❌ runs on every server
Schedule::command('invoices:generate')->monthly()->onOneServer(); // ✅ runs once

The mechanism is fine. The workflow around it is the problem: every new task needs the call, nothing enforces it, and a missed one looks identical to a correct one until real duplicates reach real users.


The Old Fix, and Why It Never Held

The workaround before 13.35 was wrapping the whole file in a group:

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

This works until the first task gets added outside the braces. The PR description names the weakness directly: it relies on everybody remembering it forever, and a junior developer can ignore it without anything failing. A protection that depends on code structure and reviewer vigilance is a protection that erodes, and it erodes silently, because an unprotected task behaves identically to a protected one on a single-server dev machine.


The One Line

// 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, which is why tasks defined later in routes/console.php are covered too, including ones added after this call. It’s off by default, so upgrading changes nothing until you opt in, and Schedule::alwaysOnOneServer(false) turns it back off. The implementation detail that makes this work is small: dueEvents() now reads through events() instead of the underlying property directly, which gives the flag a single place to apply onOneServer() to every event.

That’s the whole feature. Now the three things it depends on.


Condition One: Closures Without a Name Are Silently Skipped

onOneServer() builds its lock key from the task’s name. A command task gets one from its command line. A closure only has a name if you give it one, and calling onOneServer() on an unnamed closure throws an exception. So alwaysOnOneServer() doesn’t throw. It skips them.

// ❌ Still runs on EVERY server, with the flag on, and no warning
Schedule::call(fn () => Report::sendDaily())->daily();

// ✅ Gets a lock key, runs once
Schedule::call(fn () => Report::sendDaily())->name('send-daily-report')->daily();

This is the sharpest edge in the feature, because the failure mode is the exact symptom you were trying to cure: duplicates, with no log line explaining why. After enabling the flag, audit for it:

grep -n "Schedule::call(" routes/console.php

Every hit needs a ->name() somewhere in its chain. The same logic applies to scheduling one job with different parameters: Laravel’s scheduling docs call for a unique name() per permutation so each gets its own lock.

Schedule::job(new CheckUptime('https://laravel.com'))
    ->name('check_uptime:laravel.com');

Schedule::job(new CheckUptime('https://vapor.laravel.com'))
    ->name('check_uptime:vapor.laravel.com');

Condition Two: The Cache Has to Be Shared

The lock lives in the cache, so every server must be talking to the same cache. With the file or array store, each server has its own private lock, so every server takes it successfully and every server runs the task. The flag is on, the code is correct, and the duplicates continue.

By default the scheduler uses your application’s default store, whatever CACHE_STORE points at. If that store is local to each machine, you can redirect only the scheduler’s locks without touching the rest of your app’s caching:

public function boot(): void
{
    Schedule::alwaysOnOneServer();

    // The 'redis' store from config/cache.php, shared by every server
    Schedule::useCache('redis');
}

useCache() changes the store for the scheduler’s locks only, which covers both onOneServer() and withoutOverlapping(). If your default store is already Redis, Memcached, DynamoDB, or the database on every server, skip it.


Condition Three: There Is No Per-Task Opt-Out

Some tasks genuinely need to run on every server: clearing files from local disk, rotating a local log, warming a per-machine cache. With alwaysOnOneServer() on, those tasks get locked down to one server like everything else, and there’s no ->onEveryServer() escape hatch.

// ❌ Wants every server, but the flag will restrict it to one
Schedule::command('tmp:clear-local-disk')->hourly();

If your schedule mixes both kinds of task, the flag is the wrong tool. Leave it off and call onOneServer() on the tasks that should run once. The flag is a good fit for fleets where nearly everything is fleet-wide work (reports, invoices, syncs), and a poor fit where per-machine housekeeping is a meaningful share of the schedule.


Verifying It’s Actually Running Once

Don’t trust the config; look at what ran. A temporary hook that logs the hostname makes duplicates visible immediately:

Schedule::command('reports:send')
    ->daily()
    ->before(fn () => Log::info('reports:send starting', ['host' => gethostname()]));

With the one-server behavior working, only the server that won the lock fires the hook, so you should see exactly one entry per run. Two different hostnames in the same minute means one of the three conditions above isn’t met. This release also added onOneServer to the output of schedule:list --json (#61798), so you can inspect the effective setting per task from the command line. Check the output on your own app to confirm which field carries it, since an unnamed closure is exactly the case where you’ll want to see the answer rather than assume it.


Stopping Long Commands During a Deploy

The second half of this release is about a different duplicate: the old code and the new code running at the same time. A deploy script typically runs php artisan schedule:interrupt before switching to the new release. Before 13.35, that command set a cache flag that lasted until the end of the current minute, and only schedule:run ever checked it, to break out of its loop of sub-minute tasks.

A command started with runInBackground() is its own process and never looked at that flag. So a scheduled command that ran for an hour simply kept going on the old code, or got killed partway through when the release switched underneath it. Neither outcome is a clean stop.

cyppe’s change makes schedule:interrupt store the time of the interrupt instead of a one-minute flag, the same way queue:restart stores a restart time for queue workers. A long command can then compare that time against its own start time and stop at a safe point:

use Illuminate\Console\Command;
use Illuminate\Console\Scheduling\Schedule;

class RebuildSearchIndex extends Command
{
    protected $signature = 'search:rebuild';

    public function handle(Schedule $schedule): int
    {
        $startedAt = now();

        Product::query()->orderBy('id')->chunkById(500, function ($products) use ($schedule, $startedAt) {
            if ($schedule->hasBeenInterruptedSince($startedAt)) {
                $this->info('Deploy in progress, stopping between batches.');

                return false; // ends chunkById cleanly
            }

            foreach ($products as $product) {
                $this->index($product);
            }
        });

        return self::SUCCESS;
    }
}
Schedule::command('search:rebuild')->hourly()->runInBackground();

hasBeenInterruptedSince() returns true only for an interrupt at or after the time you pass in, so a stale interrupt from last week’s deploy can’t stop a fresh run. The same time-based check now drives schedule:run itself, which no longer stops for an interrupt that was sent earlier in the same minute.

The contract this puts on your command: it has to be safe to stop at every point where you check, and the next scheduled run has to pick up the remaining work. Here, re-indexing the whole catalog from the start is harmless because indexing is an upsert, so stopping at batch 40 of 100 loses nothing. A command that isn’t idempotent, say one that sends a notification per record with no “already sent” marker, will repeat work after an interrupt. Fix that before adding the check, not after.

Two Gotchas in the Interrupt Path

The interrupt time is stored in the default cache store, not in the store you passed to useCache(). On a multi-server setup, that default store needs to be one all servers share; if it’s a per-server file store, an interrupt written on the machine running your deploy script is invisible to commands running on the others. And if you call Schedule::withoutInterruptionPolling() to save the cache lookups, hasBeenInterruptedSince() always returns false, so the checks in your commands become dead code with no error to tell you.

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

php artisan schedule:interrupt

# switch to the new release, run migrations, and so on

php artisan queue:restart

The Decision

Does schedule:run fire on more than one server or replica?
 ├─ No  → Nothing to fix. A lock buys nothing on a single machine.
 └─ Yes → Must every task run on only one server?
           ├─ Yes → Schedule::alwaysOnOneServer() in AppServiceProvider::boot()
           │         ├─ Default CACHE_STORE shared by all servers?  → done
           │         └─ Default store is file/array?                → add Schedule::useCache('redis')
           │         Then: give every Schedule::call() closure a ->name()
           └─ No (some tasks are per-machine housekeeping)
                     → Leave the flag off. Use ->onOneServer() on the tasks that should run once.

Long-running scheduled command?
 └─ runInBackground() + hasBeenInterruptedSince($startedAt) between batches,
    and only if the next run can safely resume the remaining work.

The Verdict

alwaysOnOneServer() is the right fix for the real problem, which was never that onOneServer() was hard to write. It was that correctness depended on every future author remembering it forever. Moving that guarantee to one line in a service provider turns a convention into a default, and that’s a genuine improvement for any multi-server fleet. It is not a guarantee, though. It’s a switch with three silent preconditions: named closures, a shared cache, and a schedule with no per-machine tasks. Miss any one and you get the same duplicate emails you started with, now with a feature flag that says they shouldn’t be happening. Turn it on, then spend ten minutes proving it: grep your closures, confirm your cache store, and watch one real run’s hostname log. The deploy-interrupt half is a smaller change with the same shape. It gives long commands a clean way to stop, and it only works if the commands are written to be stopped.

Leave a Reply

Your email address will not be published. Required fields are marked *