Skip to content
intermediate Phase 14 · Queues, Caching, and Background Jobs

Scheduling Recurring Tasks

Run cleanup and report jobs on a fixed cadence.

40m
0 problems
Topic Progress 0%

One Crontab Line, Twenty Tasks: How schedule:run Decides What Runs

Someone opens crontab -e and adds a line for the nightly cleanup, another for the Monday report, another for the hourly metric rollup. Six entries later, nobody can answer which line broke — and the log of each job is a file nobody has ever opened. The scheduler exists so that all recurring work is declared in one place and triggered by one line.

One entry, many rules

System cron fires a command on a schedule; it knows nothing about your application. Laravel's scheduler flips the relationship: cron invokes one artisan command every minute, and your code decides which tasks are due.

* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

The five fields are minute, hour, day of month, month, day of week — 30 2 * * * is 02:30 daily; 0 9 * * 1 is Mondays at 09:00. With the scheduler you never write those expressions for individual tasks:

// routes/console.php (Laravel 11+; older versions: app/Console/Kernel.php schedule())
use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:monthly')->dailyAt('02:00')->timezone('Europe/London');
Schedule::command('cleanup:expired')->hourly();
Schedule::command('metrics:rollup')->everyFifteenMinutes();
Schedule::command('queue:prune --hours=48')->daily();
Schedule::function(fn () => Cache::warm())->everySixHours();
Schedule::command('invoice:overdue')->dailyAt('07:00')->weekdays();   // Mon–Fri

Read schedule:run as a matcher, not an executor: at each minute it compares the clock to every rule and runs the ones that match. That gives you declarative cadence, one place to review, and — crucially — the ability to run the whole set by hand with php artisan schedule:run while debugging.

Guarding against overlap

A 40-minute import scheduled every 15 minutes will start a second copy while the first is still running. Two flags settle it:

Schedule::command('report:build')
    ->everyFifteenMinutes()
    ->withoutOverlapping(60)   // mutex via the cache lock provider; expires after 60 min
    ->onOneServer();           // with multiple web boxes, only one runs it

withoutOverlapping() is a Cache::lock() mutex keyed on the command signature — it needs a lock-capable store (Redis, database, file, array, memcached); on a per-server store like APCu the guard protects only one box, which is why onOneServer() pairs with a shared store. The expiry argument matters: set it longer than the longest realistic run, or a hung job's lock dies early and the duplicate starts anyway.

Running the scheduler where there is no cron

Cron is a luxury of VMs. Under systemd, replace it with a timer unit — no daemons, journal-managed logs:

# /etc/systemd/system/laravel-scheduler.service
[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php artisan schedule:run

# /etc/systemd/system/laravel-scheduler.timer
[Timer]
OnCalendar=*-*-* *:*:00
AccuracySec=1s
Persistent=true        # catch up after a reboot
[Install]
WantedBy=timers.target
systemctl enable --now laravel-scheduler.timer
systemctl list-timers | grep laravel
journalctl -u laravel-scheduler.service -n 50

In containers, install cron — or run php artisan schedule:work, which loops and evaluates rules itself with a sub-minute resolution, and put it under your process manager. The rule that never changes: exactly one mechanism fires the scheduler per environment. A VM with both a crontab entry and a systemd timer runs every task twice.

Observability and failure

Silent schedulers are how tasks die unnoticed:

Schedule::command('report:build')
    ->dailyAt('02:00')
    ->appendOutputTo(storage_path('logs/scheduler.log'))
    ->onFailure(fn () => alert('report:build failed'));   // route via your notifier

php artisan schedule:list prints every rule with its next run time — run it after every change. Two traps: a server clock drift or wrong timezone makes dailyAt('02:00') fire at the wrong moment (declare ->timezone() per task), and the scheduler does not backfill — if the box is down at 02:00, that run simply never happens; Persistent=true in systemd only covers timer firing after boot, not missed business runs.

Finally, resist doing heavy work inside the scheduled closure. The scheduler process is short-lived and shared: long jobs belong on a queue, which is the subject of Running and Monitoring Workers. For the invalidation side of this phase, see Cache Invalidation Strategies.

Writing the Artisan Command a Scheduler Can Trust

The scheduler only invokes what you give it. A scheduled command that hangs, exits 0 on failure, or behaves differently under cron than under your shell will produce exactly the wrong kind of silence. Treat the command as an unattended program: explicit inputs, honest exit codes, and safe to run twice.

The shape of a schedulable command

// app/Console/Commands/BuildReport.php
namespace App\Console\Commands;

use Illuminate\Console\Command;

class BuildReport extends Command
{
    protected $signature = 'report:build
        {--days=7 : How many days to include}
        {--dry-run : Print totals without writing}';

    protected $description = 'Rebuild the daily revenue rollup';

    public function handle(ReportBuilder $builder): int
    {
        $days = (int) $this->option('days');
        $rows = $builder->build($days, dryRun: $this->option('dry-run'));

        $this->info("Rolled up {$rows} rows for {$days} days");

        return $rows > 0 ? self::SUCCESS : self::FAILURE;
        // non-zero exit is what your failure hook and systemd's OnFailure see
    }
}

Two properties make this schedulable. First, parameters live in options with defaults, so the same command runs identically from cron (report:build --days=30), from your terminal, and from a test. Second, the exit code reports reality — Schedule::command('report:build')->onFailure(...) and any CI wrapper key off FAILURE, not off grepping the log.

Idempotency: assume it will run twice

Every guard you configured in the previous chapter can fail — two servers with clocks 40 seconds apart, a lock whose expiry was too short, a human running the command manually while cron fires. Write the command so a second run is a no-op or a convergence:

// fragile: appends rows, so a duplicate run double-counts
DB::table('rollups')->insert($totals);

// idempotent: a natural key turns the duplicate into an update
DB::table('rollups')->upsert($totals, ['day'], ['revenue', 'orders']);

// or: cheap guard for expensive, non-transactional work
if (Cache::lock("report:build:{$day}", 600)->get()) {
    try { $builder->build($day); } finally { Cache::lock("report:build:{$day}")->forceRelease(); }
}

An unattended job must be safe to re-run — that single sentence prevents the entire family of "revenue counted twice after the incident" bugs.

Time, environment, and the shell difference

Cron's environment is nearly empty: no interactive PATH, no .env-loaded shell variables, a different HOME, and often a different user (www-data vs your deploy account). The failure shows up as "works when I run it, silently does nothing under cron." Defend at three levels:

Schedule::command('report:build')
    ->dailyAt('02:00')
    ->timezone('Europe/London')     // the task's clock, independent of server TZ
    ->env(['APP_ENV' => 'production']);
crontab -l                          # confirm WHICH user's crontab holds the entry
php artisan schedule:list           # every rule + next run time (run after every change)
php artisan report:build --dry-run  # exercise the same code path manually

schedule:list is the ground truth for "when will this run"; if the rule is not listed, no cron fix will help. For dates, prefer explicit cutoffs over server-local midnight: dailyAt('02:00') with ->timezone() is unambiguous, while "yesterday" computed from Carbon::now() changes meaning when the box moves timezone or when a run is delayed past midnight.

Testing without waiting for 02:00

public function test_report_is_scheduled(): void
{
    Schedule::fake();                       // or assert against the real schedule
    Schedule::assertScheduled('report:build');
}

public function test_command_returns_failure_when_nothing_to_roll(): void
{
    $this->artisan('report:build', ['--days' => 7])
         ->expectsOutputToContain('Rolled up')
         ->assertExitCode(1);
}

The command test matters more than the schedule assertion: cron wiring fails loudly (nothing runs), while a wrong exit code or a doubled rollup fails quietly. When the command passes but the cadence looks wrong, you are back in the operational layer — which is where Running and Monitoring Workers picks up the same themes for queue consumers, and where Cache Invalidation Strategies explains why the nightly cache warm exists at all.

Missed Runs, Duplicate Fires, and Silent Failures: Operating the Scheduler

Every scheduler outage has the same shape: tasks that did not run, tasks that ran twice, or tasks that ran and failed without anyone noticing. Each has a distinct cause, and the fix is a small set of operational decisions made before the incident.

Runs that never happened

The scheduler does not backfill. If the server is down at 02:00, or the crontab entry was lost during a migration, that run does not exist — there is no catch-up on recovery. Three defenses:

php artisan schedule:list              # every rule with its next due time
grep CRON /var/log/syslog | tail      # is cron itself firing?
php artisan schedule:run -vvv          # force one evaluation with verbose output
  • Assert cadence, not just existence. A monitoring check that runs schedule:list and compares the count of rules (or alerts when a known command's next-run time drifts past its window) catches the deleted crontab line within a day instead of within a quarter.
  • Make the work compensating. A daily rollup computed from raw events with an explicit cutoff (--from=2026-01-01) can be re-run for any missed day; a "process whatever arrived in the last 24 hours" job cannot recover what it slept through.
  • One firing mechanism per environment. A VM that keeps both a crontab entry and a systemd timer runs every task twice — see the timer setup in Scheduling Recurring Tasks.

Runs that happened twice

Duplicates come from multi-server deployments and from timeouts. On a four-web-server fleet every box evaluates every rule, so dailyAt('02:00') fires four times unless you call ->onOneServer() — which itself requires a shared, lock-capable cache store, because the mutex lives in the cache, not on disk:

Schedule::command('report:build')
    ->dailyAt('02:00')
    ->onOneServer()          // one server per rule, coordinated via Cache::lock
    ->withoutOverlapping(60) // and only one run at a time, lock expires in 60 min
    ->onFailure(fn () => report new SchedulerFailed('report:build'));

If withoutOverlapping() fires while the previous run is still going, the second run is skipped silently — check your logs for the skipped side too: a permanently hung process holds the lock until expiry, then lets duplicates through. A hung run is detectable as a lock older than the expected duration.

Runs that failed quietly

Cron mails nobody; stdout goes to /dev/null by default. Give every task output capture and a failure path:

Schedule::command('report:build')
    ->appendOutputTo(storage_path('logs/scheduler.log'))
    ->onFailure(fn () => alert("report:build failed at " . now()->toIso8601String()));

The rule of thumb: an unattended job that cannot page someone is a job that will fail for a month. For a single notification channel, dispatch a NotifyOps job from onFailure() — the alert itself belongs on the queue, so a slow notifier cannot wedge the scheduler.

Scheduling vs queueing — choosing the right executor

Question Scheduler Queue worker
Trigger wall clock / interval event (a dispatch)
Latency requirement minutes are fine seconds matter
Volume one run per cadence thousands of jobs
Backpressure none — it just runs workers scale, queue absorbs
Retry semantics you build them framework built in

A scheduled task that does real work should usually do one thing: dispatch jobs. Schedule::command('cleanup:expired') that loops 50 000 rows inside the scheduler process holds a cron slot and a lock for minutes; Schedule::fn(fn () => CleanupExpired::dispatch()) finishes in milliseconds and lets the worker pool absorb the load with retries and monitoring. The boundary to state in an interview: the scheduler decides when, the queue decides how fast — cron for cadence, workers for volume.

When that handoff is in place, the health of the fleet becomes the next question — exactly what Running and Monitoring Workers covers, from supervisor configs to wait-time alerts. If your rollups exist to keep cached aggregates warm, revisit Cache Invalidation Strategies to decide whether the scheduled rebuild should invalidate or overwrite.

Quiz

1. A Laravel app defines 20 scheduled tasks. What must exist in the crontab for all of them to run?

Question 1 options

2. Why does onOneServer() fail to prevent duplicate runs in a deployment where CACHE_STORE=file?

Question 2 options

3. A scheduled report that doubles its row counts after an incident restart. What property was missing?

Question 3 options

4. The server was down at 02:00 when a dailyAt('02:00') task was due. What happens when it comes back?

Question 4 options

Flashcards

Question

The one cron entry a Laravel production box needs, and what it does

Answer

`* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1` — it boots once per minute and runs only the rules whose cadence matches the current minute, so all application tasks live in code (routes/console.php) instead of scattered crontab lines.

Question

How withoutOverlapping() and onOneServer() work, and their store requirement

Answer

Both are Cache::lock()-based: withoutOverlapping takes a mutex per command signature (with an expiry you set), onOneServer lets exactly one server of the fleet run the rule. They require a shared lock-capable store (Redis, database) — a per-server store like file or APCu gives each machine its own lock.

Question

What to do when there is no cron: systemd and container options

Answer

systemd: a oneshot service (ExecStart=php artisan schedule:run) driven by a timer (OnCalendar=*-*-* *:*:00, Persistent=true). Containers without cron: run `php artisan schedule:work` under the process manager. Rule: exactly one firing mechanism per environment, or every task runs twice.

Question

The contract every scheduled artisan command must satisfy

Answer

Explicit options instead of ambient shell state; honest exit codes (SUCCESS/FAILURE) so onFailure fires; idempotent execution (upsert on a natural key or recompute from an explicit cutoff) because guards can all fail; timezone-explicit scheduling rather than server-local midnight assumptions.

Question

Scheduler vs queue: which executor for which question

Answer

The scheduler decides WHEN — wall-clock cadence, one run per interval, no built-in retry. The queue decides HOW FAST — event-driven volume, parallelism, retries and backoff. A scheduled task that processes rows should usually just dispatch a job; cron for cadence, workers for volume.

Revision Notes

Key Takeaways

  • 1. All recurrence lives in code behind one schedule:run entry (or one systemd timer) — per-task crontab lines scatter logs, environments, and failure modes
  • 2. withoutOverlapping and onOneServer are cache locks: they need a shared lock-capable store, and the expiry must exceed the longest realistic run
  • 3. Scheduled commands must be idempotent with honest exit codes, because every duplicate-fire and silent-failure scenario above can still happen
  • 4. The scheduler does not backfill — compensate by writing tasks that recompute an explicit date range
  • 5. Cron decides when, the queue decides how fast: heavy scheduled work should dispatch jobs rather than run inside the scheduler process

Interview Tips

  • • "How does Laravel's scheduler work?" — one cron line fires schedule:run every minute; it matches rules to the current time; all task definitions live in code; finish with why that beats N crontab entries (reviewable, testable, one log path)
  • • "How do you prevent a scheduled task from running twice on multiple servers?" — onOneServer plus withoutOverlapping, explaining both are Cache::lock over a shared store and what breaks with a file/APCu store
  • • "A daily job missed yesterday — how do you recover?" — state plainly that there is no backfill, then the fix: recompute with an explicit date range/idempotent upsert, and add cadence monitoring so a missing crontab line is caught within a day
  • • "Cron task works manually but not under cron" — empty environment (PATH, HOME, user), timezone assumptions, and wrong working directory; show schedule:list and exit-code checks as the diagnostic path

Cheat Sheet

Scheduling Recurring Tasks

* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
// routes/console.php
Schedule::command('report:build')
    ->dailyAt('02:00')->timezone('Europe/London')
    ->onOneServer()               // shared lock-capable cache required
    ->withoutOverlapping(60)      // mutex expires after 60 min
    ->appendOutputTo(storage_path('logs/scheduler.log'))
    ->onFailure(fn () => alert('report:build failed'));
Schedule::command('queue:prune --hours=48')->daily();
Schedule::command('horizon:snapshot')->everyFiveMinutes();

Verify: php artisan schedule:list (rules + next run) · schedule:run -vvv (manual evaluation) · schedule:test <command>.

No cron? systemd timer (OnCalendar=*-*-* *:*:00, Persistent=true) or schedule:work under the process manager — one firing mechanism per environment.

Command contract: options with defaults · exit codes that report reality · idempotent (upsert / explicit cutoff) · --dry-run for manual trials.

Never: rely on backfill (missed windows are gone) · do volume work inline (dispatch to the queue instead).

Continue Learning