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:
phpuse 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
phpSchedule::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
phpSchedule::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
- Running heavy work inline with
Schedule::command()— blocks the scheduler and pushes every subsequent task out of its minute window. - Using
filecache withonOneServer()— the "one server" guarantee is only as strong as the cache driver's atomicity.
Best Practices
- Make every scheduled task idempotent — assume it will run twice somewhere, and write it so that doesn't matter.
- Pair
onOneServer()withwithoutOverlapping($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.