DEV Community

Cover image for Laravel Scheduler at Scale: onOneServer, Overlaps, and the Timezone Trap
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Scheduler at Scale: onOneServer, Overlaps, and the Timezone Trap


Monday morning, support tickets pile up. A hundred customers got charged twice for their monthly subscription. The charge ran at 02:00, exactly when it was supposed to. It also ran a second time. You check the logs on web-1 and see the billing command fire once. You check web-2 and see it fire once too. Same minute, same command, two charges.

Nobody wrote a bug. The scheduler did exactly what you told it. You just told it something you didn't mean. There are three ways a Laravel scheduled task fires when it shouldn't (or fails to fire at all), and they stack. Here is each one, and the pattern that closes all three.

How the scheduler actually fires

Laravel's scheduler is one cron line. You define tasks in code, and a single system cron entry pokes the app every minute:

* * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

Every minute, schedule:run boots the framework, looks at your task definitions, and runs the ones whose time matches the current minute. In Laravel 11 and 12 those definitions live in routes/console.php (the old App\Console\Kernel is gone):

<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('billing:charge')
    ->dailyAt('02:00');
Enter fullscreen mode Exit fullscreen mode

That looks fine on one box. The problem starts when you scale out. You put the same deploy on web-1 and web-2 behind a load balancer, and your provisioning script installs the same crontab on both. Now two machines run schedule:run at 02:00. Both see the dailyAt('02:00') match. Both charge every customer. The scheduler has no idea another server exists.

Why your task ran on every server: onOneServer

The fix for the double charge is onOneServer(). It tells the scheduler to acquire an atomic lock before running the task, so only the first server to grab the lock in that minute executes it:

Schedule::command('billing:charge')
    ->dailyAt('02:00')
    ->onOneServer();
Enter fullscreen mode Exit fullscreen mode

There is a hard requirement here that bites people. The lock lives in your cache, and it has to be a cache every server can see. From the Laravel docs: your application must use the database, memcached, dynamodb, or redis cache driver, and every server must talk to the same central cache server. If web-1 and web-2 each have their own local file or array cache, onOneServer() does nothing. Each box grabs its own local lock and both run. This is the silent failure: it looks configured, it does nothing.

One more detail for closures and duplicated commands. onOneServer() keys the lock off the task's name. If you schedule the same command twice with different arguments, or you use a closure, give each one an explicit name so the locks don't collide:

Schedule::call(fn () => cleanupTempFiles())
    ->hourly()
    ->name('cleanup-temp-files')
    ->onOneServer();
Enter fullscreen mode Exit fullscreen mode

withoutOverlapping and the stuck-lock trap

onOneServer() stops two servers running the same minute. It does not stop the same task piling up on itself. Say a report job runs everyFiveMinutes() but sometimes takes seven minutes. Run 1 is still going when run 2 starts. Now you have two copies fighting over the same rows.

withoutOverlapping() guards that case. It holds a lock for the duration of the task and refuses to start a new run while the previous one is still holding it:

Schedule::command('reports:build')
    ->everyFiveMinutes()
    ->withoutOverlapping();
Enter fullscreen mode Exit fullscreen mode

Here is the trap. The overlap lock defaults to a 24-hour (1440-minute) expiry. That number is deliberate: if the PHP process running the task dies uncleanly (OOM kill, kill -9, a server reboot mid-run), the lock is never released. Without an expiry it would wedge forever and your task would never run again. With the 24-hour default, it wedges for a day. For a job that runs every five minutes, a day of silence is its own incident.

Set the expiry to something slightly longer than the task's realistic worst case, not the default:

Schedule::command('reports:build')
    ->everyFiveMinutes()
    ->withoutOverlapping(10); // lock auto-expires after 10 min
Enter fullscreen mode Exit fullscreen mode

Now if the process dies, the lock clears in ten minutes and the next run recovers on its own. If you ever need to clear a stuck scheduling lock by hand, there is a command for it:

php artisan schedule:clear-cache
Enter fullscreen mode Exit fullscreen mode

The timezone trap: when the clock runs twice

You survive the two servers and the overlap. Then twice a year, in the countries that still observe it, daylight saving time moves the clock, and a scheduled task runs twice or not at all.

This happens the moment you pin a task to a wall-clock time in a DST timezone:

Schedule::command('billing:charge')
    ->dailyAt('01:30')
    ->timezone('America/New_York');
Enter fullscreen mode Exit fullscreen mode

In November, when New York falls back, 02:00 becomes 01:00 and the wall clock passes through 01:30 twice. Your task fires on both. In March, when the clock springs forward, 02:00 jumps straight to 03:00 and 02:30 never exists, so a task pinned there is skipped entirely. This is not a Laravel bug. The Laravel docs say it plainly: scheduling in a timezone with a DST transition can run a task twice or skip it, and they recommend avoiding per-task timezones where you can.

The clean default is to keep the framework in UTC. UTC has no DST, so the clock never repeats or jumps. Set it once in config/app.php:

'timezone' => 'UTC',
Enter fullscreen mode Exit fullscreen mode

UTC has one downside: "run at 2am local time" drifts by an hour across the year relative to your users. For most backend jobs (billing, cleanup, exports) nobody cares whether it runs at 02:00 or 03:00 local. If a task genuinely must land at a local hour, the honest fix is not to trust the scheduler's clock. Make the task idempotent so a double fire is harmless, which you want anyway.

The safe pattern, all three closed

Stack the guards and add an idempotency key so a DST double-fire (or any retry) can't double-charge. The schedule stays in UTC:

Schedule::command('billing:charge')
    ->dailyAt('06:00') // 06:00 UTC, no DST drama
    ->onOneServer()
    ->withoutOverlapping(30);
Enter fullscreen mode Exit fullscreen mode

The command itself claims the day's run before doing work:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;

class ChargeSubscriptions extends Command
{
    protected $signature = 'billing:charge';

    public function handle(): int
    {
        $key = 'billing:charge:' . now()->toDateString();

        // add() is atomic: true only for the first caller today
        if (! Cache::add($key, true, now()->addHours(25))) {
            $this->info('Already charged today, skipping.');

            return self::SUCCESS;
        }

        // ... run the charge exactly once per day
        return self::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

Cache::add() writes the key only if it does not already exist, and returns false if it does. That is your last line of defence. Even if two servers slipped past onOneServer() because someone misconfigured the cache, even if the clock ran 01:30 twice, the second call sees the key and backs out. The date-stamped key means tomorrow starts clean.

Three layers, three failure modes. onOneServer() for the horizontal fan-out, withoutOverlapping() with a real expiry for the slow-task pile-up, and an idempotency key for the clock that lies. None of them is enough alone. Together they make "ran twice" impossible instead of unlikely.


If this was useful

The real safety in that last example isn't the scheduler config. It's the command refusing to do the same work twice. The scheduler is framework plumbing at the edge; "charge each subscription once per day" is a domain rule. When that rule owns its own idempotency, it stops depending on whether cron, the cache driver, and the timezone all lined up. Keeping that concern in the domain, out of the framework's timing machinery, is the habit Decoupled PHP is built around: the architectural layer your Laravel codebase reaches for after it outgrows the framework defaults.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)