Scheduling Recurring Tasks with the Laravel Scheduler
Most applications need to do something on a timer: send a nightly digest, prune stale records, retry failed payments, generate reports. The old way was a crontab full of cryptic entries scattered across servers, impossible to version-control and easy to forget. Laravel's scheduler puts all of that in your codebase as readable, expressive PHP. This tutorial walks through setting it up properly.
Step 1: The One Cron Entry You Need
The scheduler works by having a single cron entry call Laravel once a minute. Laravel then decides what, if anything, is due to run. Add this one line to your server's crontab:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
That is the only crontab entry you will ever add again. Everything else lives in your code.
Step 2: Define a Scheduled Command
In Laravel 13, scheduling lives in routes/console.php. Define what runs and how often using the fluent API:
use Illuminate\Support\Facades\Schedule;
Schedule::command('digest:send')->dailyAt('07:00');
Schedule::command('records:prune')->weekly()->sundays()->at('02:30');
Schedule::command('payments:retry')->everyThirtyMinutes();
The method names read like plain English, and there are dozens of them: hourly(), dailyAt(), weekdays(), lastDayOfMonth(), cron('* * * * *') for full control, and many more.
Step 3: Schedule Closures and Jobs Too
You do not need a full Artisan command for everything. Schedule a closure inline for quick tasks, or dispatch a queued job on a timer:
Schedule::call(function () {
DB::table('sessions')->where('last_activity', '<', now()->subWeek()->getTimestamp())->delete();
})->daily();
Schedule::job(new GenerateMonthlyInvoices)->monthlyOn(1, '00:00');
Scheduling a job is the better choice for heavy work, since it hands the actual processing off to your queue workers and keeps the scheduler itself fast.
Step 4: Prevent Overlaps
This is the step people skip and then regret. If a task takes longer than its interval, the next run can start before the previous one finishes, and now you have two copies fighting over the same data. withoutOverlapping() prevents that:
Schedule::command('reports:generate')
->hourly()
->withoutOverlapping();
If a run is still going when the next is due, Laravel skips the new one. For tasks that should only run on one server in a multi-server deployment, add onOneServer():
Schedule::command('newsletter:send')
->dailyAt('09:00')
->onOneServer();
This requires a shared cache like Redis so the servers can coordinate, but it guarantees exactly one server runs the task.
Step 5: Test Without Waiting
You should never have to wait until 2:30 AM to find out a task is broken. Run any due task immediately for testing:
# See everything that is scheduled and when it next runs
php artisan schedule:list
# Run whatever is currently due, right now
php artisan schedule:run
# Run the scheduler in the foreground, every minute (great for local dev)
php artisan schedule:work
schedule:work is the local development companion to the production cron entry — it keeps the scheduler running in your terminal so you can watch tasks fire.
Step 6: Know When a Task Fails
A scheduled task that silently stops running is one of the most dangerous bugs there is, because nothing looks broken. Wire up failure handling and health pings:
Schedule::command('backup:run')
->dailyAt('01:00')
->onSuccess(fn () => Log::info('Backup completed'))
->onFailure(fn () => Notification::route('mail', 'ops@example.com')
->notify(new ScheduledTaskFailed('backup:run')))
->pingOnSuccess(config('services.healthchecks.backup_url'));
The pingOnSuccess() call hits a monitoring service like Healthchecks.io or Oh Dear after a successful run. If the ping ever stops arriving, the service alerts you — so you find out a task died the next morning, not three weeks later when someone needs a backup that was never made.
You now have a complete, version-controlled scheduling setup: one cron entry, readable task definitions, overlap protection, multi-server safety, a fast local test loop, and monitoring that tells you when something breaks. Move your crontab into Laravel and you will never SSH into a box to read cron logs again.