Scheduled Jobs in Production

+15 Mana ✨

Introduction

Running scheduled tasks locally is trivial: schedule:work ticks every minute and everything Just Works. Production is harder — multiple web servers, crashed locks, silent failures, and jobs that must run exactly once. This lesson collects the patterns that make scheduled jobs boring instead of page-worthy.

Key Concepts

  • Idempotency: A job produces the same end state no matter how many times it runs. Critical when onOneServer() locks fail and two servers both run the task.
  • Lock expiration: withoutOverlapping($minutes) takes a lock that self-expires, so a crashed worker doesn't block the next run forever.
  • Schedule::job(): Push a queued job from the scheduler instead of running a command inline — the scheduler returns in milliseconds and a queue worker does the heavy lifting.

Real World Context

The most common production incident: a daily backup task runs twice because two web servers both thought they had the onOneServer lock. Or the opposite: a task never runs because a previous instance died mid-flight and the lock never released.

Deep Dive

Use Schedule::job for heavy work

Inline commands block the scheduler. If reports:generate takes 30 seconds, your scheduler is stuck for 30 seconds. Push it to the queue instead:

php
use App\Jobs\GenerateDailyReport;

Schedule::job(new GenerateDailyReport)
    ->dailyAt('02:00')
    ->onOneServer();

The scheduler dispatches the job to the queue and returns immediately. A worker picks it up and runs it.

Always set a lock expiration

php
// Bad — lock never releases if task crashes
Schedule::command('reports:generate')
    ->hourly()
    ->withoutOverlapping();

// Good — lock releases after 30 minutes worst case
Schedule::command('reports:generate')
    ->hourly()
    ->withoutOverlapping(30);

Pick an expiration longer than the task's worst-case runtime but shorter than the gap between runs.

onOneServer requires a shared cache lock

php
Schedule::command('db:archive')
    ->daily()
    ->onOneServer()
    ->withoutOverlapping(60);

This only works if your CACHE_STORE is shared across servers — Redis or a database cache driver. A per-server file cache silently lets every server run the task, because each one thinks it's the only contender for the lock.

Alert on failure

php
Schedule::command('backup:run')
    ->daily()
    ->onFailure(function () {
        Notification::route('slack', config('slack.ops_channel'))
            ->notify(new ScheduledTaskFailed('backup:run'));
    });

Silent failures are the worst kind. Wire onFailure to a notification channel that pages someone.

Common Pitfalls

  1. Running heavy work inline with Schedule::command() — blocks the scheduler and pushes every subsequent task out of its minute window.
  2. Using file cache with onOneServer() — the "one server" guarantee is only as strong as the cache driver's atomicity.

Best Practices

  1. Make every scheduled task idempotent — assume it will run twice somewhere, and write it so that doesn't matter.
  2. Pair onOneServer() with withoutOverlapping($minutes) — the two together give you "at most one, and never stuck."

Summary

  • Use Schedule::job() for anything non-trivial.
  • Always pass an expiration to withoutOverlapping().
  • onOneServer() needs a shared, atomic cache driver.
  • Wire onFailure() to an alerting channel.
✓ Completed