Web Development 5-8 minutes

Scheduled Tasks in Laravel 13: Goodbye Crontab, Hello Scheduler

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Scheduled Tasks in Laravel 13: Goodbye Crontab, Hello Scheduler

A single cron line runs php artisan schedule:run every minute, and your entire scheduled-task plan lives in PHP inside Laravel 13: no scattered crontabs, no orphan jobs, and everything under version control. The task scheduler turns your server's clock into code you can test and deploy.

Why Laravel's Scheduler Beats a Raw Crontab

A server with ten crontab lines that nobody dares to touch is more common than it looks. Every task lives outside the application, with no version control, no central logs, and no way to test it before it fails in production. Laravel 13's scheduler fixes that at the root: you define the whole schedule in PHP, and the server only needs a single cron entry.

One Cron Entry Instead of Dozens of Lines

With crontab, every task is a line with its own syntax, its own environment, and its own path to the PHP binary. With the scheduler, all of that collapses into a single entry that works on any server:

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

Every minute, Laravel evaluates the schedule defined in the application and runs only the tasks due at that moment. Adding a new task no longer means editing crontab: it is a code change like any other.

The Schedule Lives in Code: Versioning, Tests, and Deploys

Because the schedule is defined in PHP, it travels with your repository: you can review it in a pull request, run it locally, and deploy it with the same flow as the rest of the application. When a task is no longer needed, you remove it from the code and it disappears from every environment at once, leaving no orphan lines on forgotten servers.

Where to Define the Schedule in Laravel 13: routes/console.php

Since Laravel 11, the schedule lives in routes/console.php using the Schedule facade. It is a familiar file for any Laravel developer, with the same philosophy as HTTP routes: declarative, readable, and grouped.

Schedule::command, Schedule::job, and Schedule::call

The scheduler accepts three kinds of tasks: Artisan commands, queued jobs, and closures.

use Illuminate\Support\Facades\Schedule;

Schedule::command('emails:send')->daily();
Schedule::job(new GenerateSitemap)->weekly();
Schedule::call(function () {
    Cache::forget('stats_home');
})->hourly();

Commands are the most common choice because they reuse all of Artisan's machinery, with its arguments, options, and tests. Jobs enqueue work for queue workers to process, and closures handle small tasks that do not deserve their own command.

schedule:list: See the Whole Plan at a Glance

Before trusting a schedule, inspect it. The schedule:list command shows every registered task with its frequency, its next run, and its conditions, a table that usually uncovers duplicated tasks or frequencies that were not what you thought.

Frequencies: From Every Minute to Cron Expressions

everyMinute, hourly, dailyAt, and Friends

The frequency API is expressive and covers almost any need without writing a cron expression: everyMinute(), everyFiveMinutes(), hourly(), daily(), dailyAt('03:00'), weekly(), monthly(), and dozens of variations. When you need something more specific, cron('*/15 * * * *') accepts the classic expression.

Specific Days with days() and Timezones with timezone()

For tasks that only run on certain days, the days() chained method filters weekdays or concrete dates, and timezone() sets the task's timezone independently of the server's:

Schedule::command('reports:weekly')
    ->weekly()
    ->days([1, 3, 5])
    ->timezone('Europe/Madrid');

Conditions: Time Windows, Weekdays, and when()

Frequencies combine with conditions to fine-tune when a task runs. between('8:00', '20:00') limits the time window, weekdays() and weekends() select the type of day, and when() accepts a closure returning true or false: if the condition fails, the task skips that run.

Schedule::command('backups:run')
    ->dailyAt('02:00')
    ->when(fn () => disk('backups')->freeSpace() > 10 * 1024 * 1024);

Avoiding Overlaps and Duplicates

withoutOverlapping(): The Cache Lock

A task that takes longer than expected can start again before finishing, duplicating work or corrupting data. withoutOverlapping() prevents a task from launching while the previous run is still active, using a cache lock that defaults to the file driver and can be switched to Redis in multi-process applications:

Schedule::command('import:products')
    ->hourly()
    ->withoutOverlapping();

onOneServer() When the App Runs on Multiple Servers

If the application is deployed to several servers, each server's cron runs the scheduler and tasks fire multiple times. onOneServer() restricts execution to a single server, as long as the cache is shared (Redis or database), so the task runs once across the whole cluster.

runInBackground() for Long-Running Tasks

The scheduler runs tasks sequentially: if a slow task blocks the one-minute cycle, everything else is delayed. runInBackground() launches the task in the background and leaves the scheduler free to keep evaluating the rest of the plan.

Output and Notifications: Logs, Files, and Email

sendOutputTo and appendOutputTo

By default, a scheduled command's output is lost. sendOutputTo() redirects it to a file, overwriting it on each run, while appendOutputTo() appends to the end of the file to keep a history:

Schedule::command('tokens:cleanup')
    ->daily()
    ->appendOutputTo(storage_path('logs/cleanup.log'));

emailOutputTo and the onSuccess and onFailure Hooks

To know what happened without digging through logs, emailOutputTo() sends the output by mail, and the onSuccess() and onFailure() hooks react to the outcome: notify a Slack channel, flag an incident, or simply log that the task finished well.

Schedule::command('monthly:digest')
    ->monthly()
    ->emailOutputTo('admin@blenderdeluxe.com')
    ->onFailure(fn () => Log::error('The monthly digest failed'));

Development and Testing: schedule:work and schedule:test

Locally, you do not want a cron pointing at your machine. schedule:work keeps the scheduler in the foreground and runs tasks according to their frequency, ideal for checking that the plan works. To validate each task without waiting for its time to arrive, schedule:test walks the plan and asks whether you want to run each one. Combined with schedule:list, you get inspection, simulation, and real execution without touching the system crontab.

Complete Example: Maintaining a Blog on Laravel 13

A blog like this one needs little maintenance, but what it needs must happen no matter what. With the scheduler, the whole plan fits in routes/console.php and deploys with the rest of the code.

Daily Cleanup of Expired Tokens and Sessions

Schedule::command('auth:clear-resets')->daily();
Schedule::call(function () {
    DB::table('sessions')
        ->where('last_activity', '<', now()->subDays(30)->getTimestamp())
        ->delete();
})->dailyAt('04:00')->withoutOverlapping();

Weekly Sitemap and a Monthly Email Digest

Schedule::command('sitemap:generate')
    ->weekly()
    ->sundays()
    ->at('06:00');

Schedule::command('stats:monthly-digest')
    ->monthlyOn(1, '08:00')
    ->emailOutputTo('admin@blenderdeluxe.com');

The before was a crontab with loose lines, each with its own syntax and no logs. The after is a versioned file tested with schedule:test and deployed with a push. When a task fails, the onFailure hook reports it; when it takes too long, withoutOverlapping prevents double execution.

Conclusion

Laravel 13's task scheduler turns a scattered crontab into a single cron line and a versioned PHP file, with expressive frequencies, conditions, overlap locks, and notifications. Queues decide when a free worker is available to do work; the scheduler is the clock that decides when each task runs, and they combine: the scheduler enqueues jobs and Horizon processes them. If you manage servers with repetitive tasks, this is the first step toward a server that maintains itself. Keep reading the blog for more Laravel 13 guides, from queues and notifications to deploying with Sail.

Categories