Laravel Magazine
Scheduling Recurring Tasks with the Laravel Scheduler

Scheduling Recurring Tasks with the Laravel Scheduler

Eric Van Johnson ·

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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.