Advanced Scheduling Features

+15 Mana ✨

Introduction

Defining a task with ->daily() is the easy part. Making it production-ready — not overlapping itself, running on only one server in a fleet, alerting on failure, inheriting timezone and lock config from a group — is where the advanced features earn their keep.

Key Concepts

  • withoutOverlapping($minutes): Takes a cache lock before running so the previous run finishes before the next one starts.
  • onOneServer(): Atomic cache-based election that ensures only one server in a fleet runs the task.
  • Task hooks: before(), after(), onSuccess(), onFailure() let you wire arbitrary side effects around a task.
  • Schedule group: A Laravel 13 feature that shares config (timezone, onOneServer, at, etc.) across multiple tasks in one closure.

Real World Context

A daily backup that runs on a 4-server fleet must run exactly once. A weekly report that takes 20 minutes must not overlap with the next run. A critical task that silently fails at 3am is worse than one that fails loudly and pages someone. Every feature in this lesson exists to handle a specific production incident.

Deep Dive

Preventing Task Overlaps

php
Schedule::command('emails:send')
    ->withoutOverlapping()  // Skip if previous still running
    ->daily();

// With lock expiration
Schedule::command('reports:generate')
    ->withoutOverlapping(10)  // Lock expires after 10 minutes
    ->hourly();

Running on One Server

For multi-server deployments:

php
Schedule::command('report:generate')
    ->onOneServer()  // Only one server runs this
    ->daily();

Requires a cache driver that supports locks (Redis, Memcached, database).

Background Tasks

php
// Run in background (don't block other tasks)
Schedule::command('analytics:import')
    ->runInBackground()
    ->daily();

Conditional Scheduling

php
// Only run when condition is true
Schedule::command('emails:send')
    ->daily()
    ->when(function () {
        return config('mail.enabled');
    });

// Skip when condition is true
Schedule::command('backup:run')
    ->daily()
    ->skip(function () {
        return app()->isDownForMaintenance();
    });

// Only in certain environments
Schedule::command('telescope:prune')
    ->environments(['staging', 'production'])
    ->daily();

Day Constraints

php
// Only on weekdays
Schedule::command('report:generate')
    ->weekdays()
    ->at('08:00');

// Only on weekends
Schedule::command('cleanup:run')
    ->weekends()
    ->at('02:00');

// Specific days
Schedule::command('task')
    ->days([Schedule::MONDAY, Schedule::WEDNESDAY, Schedule::FRIDAY])
    ->at('09:00');

Time Constraints

php
// Only between certain hours
Schedule::command('process:queue')
    ->everyMinute()
    ->between('8:00', '17:00');  // Business hours

// Except between certain hours
Schedule::command('heavy:task')
    ->hourly()
    ->unlessBetween('9:00', '17:00');  // Not during business hours

Task Output

php
// Write output to file
Schedule::command('emails:send')
    ->daily()
    ->sendOutputTo('/var/log/email-output.log');

// Append to file
Schedule::command('emails:send')
    ->daily()
    ->appendOutputTo('/var/log/email-output.log');

// Email output
Schedule::command('report:generate')
    ->daily()
    ->emailOutputTo('admin@example.com');

// Email only on failure
Schedule::command('backup:run')
    ->daily()
    ->emailOutputOnFailure('admin@example.com');

Task Hooks

php
Schedule::command('emails:send')
    ->daily()
    ->before(function () {
        Log::info('Starting email send...');
    })
    ->after(function () {
        Log::info('Email send complete.');
    })
    ->onSuccess(function () {
        Notification::send($admin, new TaskSucceeded('emails:send'));
    })
    ->onFailure(function () {
        Notification::send($admin, new TaskFailed('emails:send'));
    });

Ping URLs (Heartbeats)

php
// Ping URL before/after task
Schedule::command('backup:run')
    ->daily()
    ->pingBefore('https://healthchecks.io/ping/start')
    ->thenPing('https://healthchecks.io/ping/end')
    ->pingOnSuccess('https://healthchecks.io/ping/success')
    ->pingOnFailure('https://healthchecks.io/ping/failure');

Viewing Scheduled Tasks

bash
# List all scheduled tasks with their next run time
php artisan schedule:list

The command prints every task defined in routes/console.php along with its cron expression and the next scheduled run time, which is invaluable for sanity-checking a complex schedule before deploying it.

Complete Example

php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

// Daily tasks
Schedule::command('backup:clean')->daily()->at('01:00');
Schedule::command('backup:run')->daily()->at('02:00');

// Email reports
Schedule::command('report:daily')
    ->dailyAt('08:00')
    ->weekdays()
    ->emailOutputOnFailure('admin@example.com');

// Cleanup
Schedule::command('telescope:prune --hours=48')
    ->daily()
    ->environments(['production']);

Schedule::command('queue:prune-failed --hours=168')
    ->weekly();

// Processing
Schedule::command('process:pending-orders')
    ->everyFiveMinutes()
    ->withoutOverlapping()
    ->onOneServer();

// Maintenance (off-hours only)
Schedule::command('db:optimize')
    ->weekly()
    ->sundays()
    ->at('03:00')
    ->unlessBetween('08:00', '22:00');

Schedule Groups (Laravel 13)

Laravel 13 added schedule groups — a way to share configuration across several tasks without repeating it on each line. Call a frequency/config chain, then pass a closure containing the actual commands:

php
Schedule::daily()
    ->onOneServer()
    ->timezone('America/New_York')
    ->at('09:00')
    ->group(function () {
        Schedule::command('emails:send --force');
        Schedule::command('reports:generate');
        Schedule::command('analytics:sync');
    });

All three commands inherit daily(), onOneServer(), timezone('America/New_York'), and at('09:00'). Before 13, you had to duplicate those four modifiers on every line — which was error-prone whenever one task drifted out of sync with the rest.

Common Pitfalls

  1. Using file cache with onOneServer() — the "one server" guarantee relies on an atomic cache lock. A per-server file cache means every server thinks it holds the lock. Use Redis, Memcached, or a shared database cache.
  2. withoutOverlapping() without an expiration — if a task crashes mid-run, the lock stays held forever. Always pass a number of minutes: withoutOverlapping(30).

Best Practices

  1. Pair onOneServer() with withoutOverlapping($minutes) — the combination gives you "at most one, and never stuck".
  2. Wire onFailure() to your alerting channel — silent scheduled task failures are one of the most common "why didn't we notice?" postmortems.

Summary

  • withoutOverlapping($minutes) and onOneServer() are the two foundational production guards.
  • before/after/onSuccess/onFailure hooks wire cross-cutting logic around tasks.
  • Laravel 13's schedule groups collapse shared configuration into one closure.
  • schedule:list prints every task with its next run time for sanity-checking.
✓ Completed