Laravel task scheduling lets you define every recurring job in PHP inside your application, then run all of them from one cron entry that calls php artisan schedule:run every minute. You write the schedule in code, commit it, and never edit a crontab again.

Most tutorials on this topic still tell you to open app/Console/Kernel.php. That file does not exist in a fresh Laravel 11 or 12 install, so half the advice you find fails on step one.

You already know cron syntax. That was never the hard part. The hard part is the schedule that works on your laptop, runs twice once there are two web servers, overlaps itself as data grows, and fails at 03:00 with nobody watching.

This guide covers where schedules live in every supported Laravel version, the frequency and constraint methods worth memorising, the five production failures that take scheduled tasks down, and a checklist for a scheduler that refuses to fire.

Key Takeaways

What Laravel task scheduling is and why it replaces raw cron jobs

Laravel task scheduling is a fluent PHP API for defining recurring work inside your application. Rather than one crontab entry per job, you register every task in code and let a single cron entry ask Laravel once a minute which tasks are due. Nothing runs when nothing is due.

The difference matters more than it sounds. A raw crontab is invisible to version control, invisible to your team, and slightly different on every server you own. A schedule defined in PHP ships with the code it runs.

Concern

Raw crontab

Laravel scheduler

Entries to maintain

One per job

One, forever

In version control

No

Yes

Conditional logic

Shell tricks

Plain PHP closures

Overlap protection

Manual lock files

withoutOverlapping()

Multi server safety

Manual

onOneServer()

Changing a run time

SSH into every box

Commit and deploy

The single cron entry every Laravel app needs

The Laravel cron job you install is one line, and it is the same line for every project. Add it to the crontab of the user that owns your application files.

* * * * * cd /var/www/your-app && php artisan schedule:run >> /dev/null 2>&1

Cron fires schedule:run every minute. Laravel evaluates your registered tasks, runs the ones whose time has come, and exits. Two details break this line more often than anything else. Use an absolute path to the PHP binary when a server has more than one version installed, and install the crontab for your deploy user rather than root, or your storage and log files end up owned by the wrong account.

* * * * * cd /var/www/your-app && /usr/bin/php8.3 artisan schedule:run >> /dev/null 2>&1

The official Laravel scheduling documentation keeps this entry current for every release, and it has barely changed in years.

Writing the command, registering the schedule, and covering it with a test is the slow half of shipping a recurring feature. You can Try StackMivo Now, describe the job in plain English, and get production-ready Laravel apps with the models, migrations, controllers, and Pest tests around it, all of it yours to edit like any other file in the repo.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free

Where Laravel task scheduling lives in Laravel 11 and 12

Laravel 11 removed app/Console/Kernel.php from the default skeleton. Schedules moved to two places, and both are still correct in Laravel 12.

routes/console.php with the Schedule facade

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('carts:prune --days=14')->dailyAt('03:15');
Schedule::command('sessions:prune')->hourly();
Schedule::command('reports:weekly')->weeklyOn(1, '08:00');

The Schedule facade shipped with Laravel 11 and is the shortest route for command based tasks. Everything registered here behaves exactly like the older Kernel method.

bootstrap/app.php when you want one place to see everything

// bootstrap/app.php
use Illuminate\Console\Scheduling\Schedule;

return Application::configure(basePath: dirname(__DIR__))
    // other configure calls omitted
    ->withSchedule(function (Schedule $schedule) {
        $schedule->command('backup:run')->dailyAt('01:00');
    })
    ->create();

app/Console/Kernel.php on Laravel 9 and 10

// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('carts:prune')->dailyAt('03:15');
}

Upgraded an older app to Laravel 11 or 12 and kept the Kernel file? Your existing schedule() method still runs. Nothing forces a migration, so move it when you are already working in that area.

Version drift is exactly where generic AI assistants trip up. They suggest the Kernel file for a Laravel 12 project because most of their training data predates the skeleton change. StackMivo's Laravel-native intelligence targets Laravel 9 through 12, so generated work lands where the version you actually run expects it.

Laravel schedule command frequencies and constraints

Every Laravel task scheduling entry starts with a frequency method. These are the ones you will reach for most.

Method

When it runs

everyMinute()

Every minute

everyFiveMinutes()

Every five minutes

hourly() and hourlyAt(15)

Top of the hour, or at minute 15

daily() and dailyAt('02:00')

Midnight, or a set time

twiceDaily(1, 13)

01:00 and 13:00

weeklyOn(1, '08:00')

Mondays at 08:00

monthlyOn(1, '00:30')

First of the month at 00:30

quarterly() and yearly()

Start of the quarter or year

cron('0 */6 * * *')

Any raw cron expression

Constraints stack on top of a frequency and narrow when the task may actually run.

Schedule::command('reports:weekly')
    ->weeklyOn(1, '08:00')
    ->timezone('Asia/Kolkata')
    ->when(fn () => config('reports.weekly_enabled'))
    ->environments('production');
  • weekdays(), weekends(), and days([1, 3, 5]) limit which days qualify.

  • between('09:00', '17:00') and unlessBetween('00:00', '06:00') limit the window.

  • when() and skip() take a closure and decide at runtime.

  • environments('production') stops staging from emailing your customers.

When no frequency method fits, drop to a raw expression with cron('0 */6 * * *'). Crontab guru is the fastest way to confirm an expression means what you think it means.

What you can schedule beyond Artisan commands

Commands are the common case, not the only one. The scheduler accepts four kinds of work.

  1. Artisan commands with Schedule::command('carts:prune'), the most testable option.

  2. Queued jobs with Schedule::job(new SendWeeklyDigest), which pushes onto your queue instead of running inline.

  3. Closures with Schedule::call(fn () => Cache::forget('homepage')), fine for genuine one liners.

  4. Shell commands with Schedule::exec('node scripts/thumbnails.js') for work that lives outside PHP.

Write the command, then schedule it

php artisan make:command PruneStaleCarts
// app/Console/Commands/PruneStaleCarts.php
class PruneStaleCarts extends Command
{
    protected $signature = 'carts:prune {--days=7}';

    protected $description = 'Delete carts abandoned before the cutoff';

    public function handle(): int
    {
        $cutoff = now()->subDays((int) $this->option('days'));

        $deleted = Cart::whereNull('ordered_at')
            ->where('updated_at', '<', $cutoff)
            ->delete();

        $this->info("Pruned {$deleted} stale carts.");

        return self::SUCCESS;
    }
}

A command with a signature and options is easy to run by hand, easy to test, and easy to reason about at 03:00. If Artisan generator flags are still muscle memory you are building, the php artisan make:model cheatsheet covers the options worth knowing.

Push long work onto a queue

A Laravel team lead at a mid-size SaaS had a nightly digest defined as a closure right in the schedule. It took nine seconds the week they wrote it. A year of growth later it took eleven minutes, and because schedule:run works through due tasks in order, everything behind it started late. The fix was four lines. They moved the logic into a queued job, registered it with Schedule::job(new SendWeeklyDigest), and let a queue worker absorb the runtime.

Anything that calls an external API, sends mail in bulk, or scales with row count belongs in a queued job rather than the scheduler process. Nightly reports and cleanup routines behind internal tools are the usual suspects.

Why Laravel task scheduling breaks in production and how to fix it

Laravel task scheduling works on the first try in local development almost every time. Production is where the interesting failures live, and these five account for most of them.

Overlapping runs stack up

An hourly task that starts taking 70 minutes will run alongside itself. Duplicate emails, double counted totals, and database deadlocks follow.

Schedule::command('invoices:generate')
    ->hourly()
    ->withoutOverlapping(30);

That method takes an atomic cache lock before the task starts and releases it on completion. The optional argument is the number of minutes after which a stale lock expires, which matters when a process is killed rather than finishing. Set it a little above your worst realistic runtime.

Every server runs the same task

An agency moved a client app behind a load balancer and provisioned a second web server from the same script, crontab included. Every scheduled report went out twice, and the client spotted it before they did. onOneServer() settles it by letting the first server to acquire the lock win.

Schedule::command('reports:daily')
    ->dailyAt('06:00')
    ->onOneServer();

Two conditions apply. All servers must share one cache store, so redis, memcached, database, or dynamodb, never file or array. And if you schedule the same command twice with different arguments, give each a unique name() so they do not fight over a single lock.

One slow task delays the rest

By default schedule:run processes due tasks sequentially inside one PHP process. Add runInBackground() to a task that does not need to block the others and it runs in its own process instead.

Failures stay silent

The >> /dev/null 2>&1 at the end of the crontab line throws away everything the scheduler has to say. Put the visibility back at the task level, where it belongs.

Schedule::command('backup:run')
    ->dailyAt('01:00')
    ->appendOutputTo(storage_path('logs/backup.log'))
    ->onFailure(fn () => Log::error('Nightly backup failed'))
    ->pingOnFailure('https://uptime.example.com/hooks/backup');

The onSuccess(), onFailure(), before(), and after() hooks cover in app reactions. The ping family calls a URL, which is how you wire a scheduled task to an external heartbeat monitor that alerts you when the ping stops arriving.

Timezones and daylight saving drift

A task pinned to a timezone that observes daylight saving can be skipped or run twice on the two changeover days each year. Keep money and data critical jobs on UTC and convert for display, or move the schedule out of the small hours where the clock shifts.

Schedule::command('payouts:run')->dailyAt('14:00')->timezone('UTC');

Each of these fixes is one method call once you know it exists. Building the feature underneath is the slower part, so Get started free and let StackMivo generate production-ready Laravel apps with models, migrations, controllers, auth, and Pest tests, then add the scheduling constraints yourself.

How to test and debug a Laravel scheduler that is not running

Three Artisan commands answer most Laravel task scheduling questions before you open a log file.

  • php artisan schedule:list prints every registered task with its next due time, which immediately exposes a task that is registered but constrained out.

  • php artisan schedule:test lets you pick one task and run it now, without waiting for its window.

  • php artisan schedule:work runs the scheduler in the foreground. The docs frame it as a local development tool, and it is also how container setups without a crontab keep a scheduler alive under a process supervisor.

Checklist for a scheduler that never fires

  1. Wrong PHP binary or path. Run the crontab line by hand as the deploy user. Most of the time it fails there too, and the error tells you why.

  2. Crontab on the wrong user. Check with crontab -l -u deploy, not just crontab -l as root.

  3. Output discarded. Temporarily swap >> /dev/null 2>&1 for a real log path and watch a few minutes of runs.

  4. Environment constraint. An environments('production') call plus an APP_ENV of staging silently skips everything.

  5. Maintenance mode. Tasks are skipped while the app is down unless you add evenInMaintenanceMode().

  6. Cache store cannot hold locks.withoutOverlapping() and onOneServer() behave unpredictably on the file or array drivers.

  7. Crontab lost on rebuild. The cron entry lives on the server, not in your repository.

That last one deserves a second look after any infrastructure change. StackMivo's one-click deployment runs migrations, clears caches, and restarts queue workers for you, while the crontab entry stays part of server provisioning, so verify it on every new or rebuilt box.

Test the command, not the clock

Never write tests that wait for a schedule. Test the command the schedule calls and trust Laravel to handle the timing.

it('prunes carts abandoned before the cutoff', function () {
    $stale = Cart::factory()->create(['updated_at' => now()->subDays(30)]);
    $fresh = Cart::factory()->create(['updated_at' => now()->subHour()]);

    $this->artisan('carts:prune --days=7')->assertExitCode(0);

    expect(Cart::find($stale->id))->toBeNull();
    expect(Cart::find($fresh->id))->not->toBeNull();
});

With schedule:list proving registration and a Pest test proving behaviour, the only thing left to trust is one line of crontab.

Scheduling recurring work the Laravel way

Laravel task scheduling trades a sprawling crontab for one cron entry and a schedule that lives in your repository. Register tasks in routes/console.php on Laravel 11 and 12, or in the Kernel on Laravel 9 and 10. Pick a frequency, narrow it with constraints, and hand long work to a queue instead of running it inside the scheduler process.

Then spend ten minutes on the production details. Add withoutOverlapping() to anything that could outlive its window, onOneServer() to anything running behind a load balancer with a shared cache store, and an onFailure() hook or a failure ping to anything you would want to know about at 03:00.

Run php artisan schedule:list against your current project before you close this tab. If a task you assumed was running has no next due date, you just found today's fix. Get the recurring half of your application visible and it stops being the part nobody wants to touch.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free