The Scheduler
Defining scheduled tasks in routes/console.php, the single cron entry that drives them, and preventing overlap or duplicate runs.
Schedule::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
<?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.
<?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.
# 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:interruptReading 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.
<?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