Skip to content
Go back

An Introduction to the Laravel Scheduler

Table of contents

Open Table of contents

What is the Laravel Scheduler?

The Scheduler is Laravel’s built-in task runner. You call it via a cron entry — typically every minute, as the official docs recommend — and it checks whether any of your defined tasks are due. When one is due, it either runs the command directly or dispatches it as a job for the queue worker to pick up.

Think of it like an alarm clock that goes off every minute. Each time it rings, it checks a list of tasks and asks: is anything due right now? If yes, it triggers the task. If not, it goes back to sleep. The whole check takes less than a second.

The key idea: instead of creating a separate cron job for every task your app needs to run, you define all your tasks in code — inside routes/console.php — and a single cron entry handles the rest.

// routes/console.php
Schedule::command('reports:send')->daily()->at('00:01');
Schedule::command('cleanup:old-records')->weekly()->mondays();

This means your schedule is version-controlled, visible to your team, and can be inspected with a single command (php artisan schedule:list).

What Growth Method uses the scheduler for

Growth Method runs three automated tasks on the scheduler.

1. The campaign nudger

Command: idea:update-stage Runs: Daily at 06:00

Every morning at 6am, this task checks if any campaigns have been running longer than the team’s configured duration target (e.g. 14 days). If a campaign has been live too long, it automatically moves it from “Live” to “Analysing” and emails the campaign owner.

In plain language: it’s like a project manager tapping someone on the shoulder and saying, “time to review your results.” Without it, campaigns would sit in the “Live” stage forever unless someone remembered to move them manually.

$ideas = Idea::where('stage', 'Live')
    ->with('team')
    ->get()
    ->filter(function ($idea) {
        $daysSinceStart = $idea->date_test_inprogress->diffInDays(now());
        return $daysSinceStart > ($idea->team->experiment_duration_target + 1);
    });

foreach ($ideas as $idea) {
    $idea->stage = 'Analysing';
    $idea->date_test_analysing = now();
    $idea->save();

    StageAutomaticallyMovedToAnalysing::dispatch($idea);
}

2. The team growth report

Command: app:send-growth-report --all Runs: Daily at 00:01

Every night just after midnight, this task loops through every team and checks if today matches that team’s chosen report day. Each team picks their own day — Team A might get reports on Mondays, Team B on Fridays. If it’s their day, it generates and emails a growth summary report. If not, it skips them.

3. The individual report

Command: app:send-individual-report Runs: Daily at 00:05

Same pattern, but per-person. Each user gets their own weekly email showing how many campaigns they have in each stage — Building, Live, and Analysing. The day is configurable per team, so this task also runs daily and checks internally.

Why not use weekly scheduling?

If these reports only send once a week, why not use Laravel’s ->weeklyOn() method instead of running daily and checking the day inside the command?

Each team configures their own weekly_report_day and individual_report_day in the database. You can’t use ->weeklyOn(1, '00:01') because Team A wants Monday and Team B wants Friday. The only way to handle this is to run daily and check each team’s preference — which is exactly what the code does.

This is the correct pattern for per-tenant scheduling. You can’t express “Monday for Team A, Friday for Team B” in a cron expression.

Scheduler vs queue worker

The scheduler and queue worker serve different purposes. You typically need both.

SchedulerQueue Worker
PurposeTriggers tasks at specific timesProcesses jobs as they arrive
AnalogyAn alarm clockA cashier
When it runsEvery minute (via cron)Continuously (long-lived process)
Use caseDaily reports, cleanup tasks, periodic checksSending emails, processing uploads, API calls
How tasks are definedIn code (routes/console.php)Dispatched from anywhere in your app

At Growth Method, we use both. The scheduler triggers reports at the right time. Some of those tasks dispatch events to listeners that are processed by the queue worker. They work together — the scheduler is the alarm clock, the queue worker is the cashier.

For more on queue workers and why we chose a Forge queue worker over Horizon, see Why I Replaced Laravel Horizon with a Forge Queue Worker.

Is running a cron job every minute inefficient?

My first reaction was scepticism:

It seems really inefficient to be running a per-minute scheduled task like this?

The per-minute cron is like a security guard doing a walkthrough — he checks a few doors every minute and almost always walks away having done nothing. The walkthrough itself is nearly free: a tiny PHP process starts, checks the time against the schedule, sees nothing is due, and exits. The whole thing takes less than a second and uses negligible resources.

The real work only happens at the specific times you’ve defined. The other 1,439 minutes per day, the scheduler starts, checks, and exits immediately.

This is the standard Laravel pattern. Every Laravel app in production does it. The official scheduling documentation is explicit:

Add a single cron entry to your server that runs the schedule:run command every minute.

Why not use separate cron jobs at the exact times?

You could create three individual cron jobs — one at 00:01, one at 00:05, one at 06:00. But you’d lose several things:

The one-cron-many-tasks pattern exists so your schedule lives in code, not on the server.

What the community says

Taylor Otwell designed the scheduler this way intentionally. He built withoutOverlapping(), onOneServer(), and runInBackground() directly into the framework’s scheduling system.

Mohamed Said, Laravel’s queue expert, focuses on queue fundamentals and Supervisor in his Laravel Podcast episode on queues. He doesn’t suggest complex alternatives as defaults, reinforcing that simple is better.

Freek Van der Herten (Spatie) recommends monitoring your scheduled tasks with spatie/laravel-schedule-monitor. He warns that error trackers like Flare will not alert you when a scheduled task fails to run or runs late.

Adding production safeguards

My original schedule was bare:

Schedule::command('idea:update-stage')->daily()->at('06:00');
Schedule::command('app:send-growth-report --all')->daily()->at('00:01');
Schedule::command('app:send-individual-report')->daily()->at('00:01');

It worked, but left three edge cases unprotected: overlapping runs, duplicate execution across servers, and sequential blocking.

Schedule::command('idea:update-stage')
    ->daily()->at('06:00')
    ->withoutOverlapping()
    ->onOneServer();

Schedule::command('app:send-growth-report --all')
    ->daily()->at('00:01')
    ->withoutOverlapping()
    ->onOneServer()
    ->runInBackground();

Schedule::command('app:send-individual-report')
    ->daily()->at('00:05')
    ->withoutOverlapping()
    ->onOneServer()
    ->runInBackground();

What each safeguard does

withoutOverlapping() prevents a task from running again if the previous run hasn’t finished. It uses a cache lock that expires after 24 hours by default. If your growth report takes longer than expected, tomorrow’s run won’t stack on top of it.

onOneServer() restricts a task to one server. If you scale to multiple servers without this, every server runs every task — and your team receives duplicate reports. This requires a shared cache driver like Redis.

runInBackground() lets the two report commands run concurrently instead of sequentially. By default, the scheduler runs tasks one after another. If the growth report takes 5 minutes, the individual report would wait. With runInBackground(), they both start independently.

Staggered times — I moved the individual report from 00:01 to 00:05. This avoids both report commands hitting the database and the Bento API at the same time.

Verifying your schedule

Three useful commands for working with the scheduler:

# Show all tasks and their next run time
php artisan schedule:list

# Execute any tasks due right now (this is what the cron runs)
php artisan schedule:run

# Run the scheduler in the foreground for local development
php artisan schedule:work

On Forge, you can verify everything is registered by SSHing into your server and running schedule:list:

0 6 * * *  idea:update-stage .............. Next Due: 19 hours from now
1 0 * * *  app:send-growth-report --all ... Next Due: 13 hours from now
5 0 * * *  app:send-individual-report ..... Next Due: 13 hours from now

If you see your tasks with correct cron expressions and next-due times, your schedule is working.


Back to top ↑