The Scheduler

Defining scheduled tasks in routes/console.php, the single cron entry that drives them, and preventing overlap or duplicate runs.

SyntaxSchedule::command(string $command, array $parameters = [])->dailyAt('03:00')->withoutOverlapping()

The scheduler keeps your recurring tasks in your codebase instead of in a crontab. You declare them in routes/console.php with the Illuminate\Support\Facades\Schedule facade, and the server gets one cron entry that runs php artisan schedule:run every minute. That command works out which tasks are due now and runs those.

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

Declaring a task

Schedule::command() runs an Artisan command, Schedule::job() dispatches a queued job, Schedule::call() runs a closure and Schedule::exec() runs a shell command. The frequency methods are readable spellings of cron expressions, and both forms mean the same schedule: ->daily() is ->cron('0 0 * * *'), and ->dailyAt('03:00') is ->cron('0 3 * * *'). Likewise Schedule::job(new Heartbeat) is the shorthand for Schedule::call(fn () => Heartbeat::dispatch()).

Two things that bite

A task slower than its interval will start again while the last run is still going. withoutOverlapping() takes a cache lock for the task and skips the run if it is held. If the scheduler runs on several servers, every one of them runs every due task; onOneServer() makes the first server to take an atomic lock the only one that runs it, which needs a shared lock-capable cache driver.

php artisan schedule:list prints the schedule with next run times. php artisan schedule:work runs the scheduler in the foreground for local development.

Example

Example · php
<?php
// app/Console/Commands/PruneStaleCarts.php
// Generated by: php artisan make:command PruneStaleCarts

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Cart;
use Illuminate\Console\Command;

class PruneStaleCarts extends Command
{
    /**
     * The name and signature of the console command.
     */
    protected $signature = 'carts:prune {--days=7}';

    /**
     * The console command description.
     */
    protected $description = 'Delete carts that have not been touched recently';

    /**
     * Execute the console command.
     */
    public function handle(): int
    {
        $days = (int) $this->option('days');

        $deleted = Cart::query()
            ->whereNull('ordered_at')
            ->where('updated_at', '<', now()->subDays($days))
            ->delete();

        $this->info("Deleted {$deleted} stale carts older than {$days} days.");

        return self::SUCCESS;
    }
}

When to use it

  • Delete abandoned carts every night at three in the morning.
  • Refresh exchange rates every five minutes by queueing a job the workers run.
  • Generate a Friday evening report exactly once across three application servers.
  • Reset per-day usage counters at midnight in the business's own timezone.

More examples

The schedule itself

runInBackground is worth knowing: without it, tasks due in the same minute run one after another, so a slow report delays everything scheduled behind it. It only works with command and exec. If you would rather keep routes/console.php for command definitions, the withSchedule closure in bootstrap/app.php registers the same tasks against an Illuminate\Console\Scheduling\Schedule instance.

Example · php
<?php
// routes/console.php

declare(strict_types=1);

use App\Jobs\RefreshExchangeRates;
use App\Models\User;
use Illuminate\Support\Facades\Schedule;

// An Artisan command, with its options written as you would type them.
Schedule::command('carts:prune --days=14')
    ->dailyAt('03:00')
    ->withoutOverlapping()
    ->runInBackground();

// Identical schedule, written as cron.
// Schedule::command('carts:prune --days=14')->cron('0 3 * * *');

// Queue a job every five minutes. The scheduler only dispatches it;
// a worker does the work.
Schedule::job(new RefreshExchangeRates)->everyFiveMinutes();

// A closure needs a name before it can run on one server.
Schedule::call(fn () => User::query()->update(['api_calls_today' => 0]))
    ->name('reset-api-call-counts')
    ->daily()
    ->onOneServer();

// Shared settings, applied once.
Schedule::daily()->onOneServer()->timezone('Europe/Berlin')->group(function () {
    Schedule::command('reports:daily');
    Schedule::command('reports:email');
});

Installing and inspecting the schedule

Resist the urge to add one cron line per command. Overlap prevention, onOneServer, maintenance-mode handling and the output hooks all live inside schedule:run, so a task invoked directly by cron gets none of them. If a task works under schedule:work but not under cron, the usual causes are a different PATH, the wrong php binary or a missing cd into the project directory.

Example · shell
# The only cron entry you need. Every task lives in your code.
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

# What is registered, and when each task next runs.
php artisan schedule:list

# Local development: runs the scheduler in the foreground.
php artisan schedule:work

# Release a stuck withoutOverlapping lock.
php artisan schedule:clear-cache

# In a deploy script, if you use sub-minute tasks: schedule:run keeps
# running for the whole minute, so tell the old one to stop.
php artisan schedule:interrupt

Reading a frequency as the minutes it fires on

Every frequency helper resolves to a cron expression, and this is that expression's meaning written as data. It also makes the sizing question concrete: if the fifteen-minute task takes eighteen minutes, withoutOverlapping will skip every other run, and the fix is a faster task or a longer interval, not a bigger lock.

Example · php
<?php

// everyFifteenMinutes() is ->cron('0,15,30,45 * * * *').
// These are the minutes of each hour it fires on.
$minutes = collect(range(0, 59))
    ->filter(fn (int $minute) => $minute % 15 === 0)
    ->values();

echo $minutes->implode(', ');
// Output:
// 0, 15, 30, 45

// And the gap between runs, which is the number your task must
// finish inside if you want to avoid withoutOverlapping skips.
echo $minutes->sliding(2)->map(fn ($pair) => $pair->last() - $pair->first())->implode(', ');
// Output:
// 15, 15, 15

Discussion

  • Be the first to comment on this lesson.