Laravel Task Scheduling Reference

A task scheduling reference for Laravel's scheduler: running commands and closures on a recurring basis.

Defining scheduled tasks

Since Laravel 11, scheduled tasks are defined directly in routes/console.php using the Schedule facade — there's no app/Console/Kernel.php to edit. (Older apps upgraded from Laravel 10 or earlier may still define theirs in the Kernel's schedule() method.) Schedule::command() can target any built-in or custom command — see the Artisan commands guide for writing one.

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

Schedule::command('emails:send')->daily();

Schedule::call(function () {
    DB::table('recent_users')->delete();
})->daily();

Schedule::job(new ProcessPodcasts)->everyFiveMinutes();

// Run an arbitrary OS-level command, not just an Artisan one
Schedule::exec('node /home/forge/script.js')->daily();

Frequency options

MethodRuns
everyMinute()Every minute.
everyFiveMinutes()Every 5 minutes (also 10/15/30 variants).
hourly()Once per hour, on the hour.
hourlyAt(17)Once per hour, at 17 minutes past.
daily()Once per day at midnight.
dailyAt('13:00')Once per day at the given time.
twiceDaily(1, 13)Twice per day, at the given hours.
weekly()Once per week (Sunday at midnight by default).
weeklyOn(1, '8:00')Once per week, on the given day/time.
monthly()Once per month, on the 1st.
quarterly() / yearly()Once per quarter / year.
cron('* * * * *')A raw cron expression, for anything the helpers don't cover (double-check one with the cron parser).

Constraining when a task runs

Schedule::command('emails:send')
    ->daily()
    ->weekdays()
    ->between('8:00', '17:00')
    ->environments(['production']);

Schedule::command('reports:generate')
    ->daily()
    ->when(fn () => DB::table('orders')->exists());

Preventing task overlap

If a task might still be running when its next scheduled run fires (e.g. a slow import), guard against overlapping executions:

Schedule::command('reports:generate')
    ->dailyAt('02:00')
    ->withoutOverlapping();

// On a multi-server deployment, make sure only one server runs it
Schedule::command('reports:generate')
    ->dailyAt('02:00')
    ->onOneServer();

// Don't block the scheduler while a long task runs
Schedule::command('import:large-dataset')
    ->daily()
    ->runInBackground();

Running the scheduler

Laravel's scheduler still needs exactly one real cron entry to check what's due every minute — individual tasks are never registered as separate cron jobs themselves.

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

For local development, run the scheduler in the foreground instead of waiting on real cron (also listed in the Artisan commands guide):

php artisan schedule:work

The single cron entry is the most common source of confusion for newcomers: adding a new Schedule::command() line never requires touching crontab again, since schedule:run is what re-reads routes/console.php every minute and decides what's actually due. If nothing seems to fire, the cron entry itself — not the schedule definitions — is usually the first thing to check. php artisan schedule:list prints every registered task along with its next run time, the fastest way to confirm a task is actually registered the way you expect without waiting for it to fire.

php artisan schedule:list

Timezones

By default, scheduled times are evaluated against the server's configured timezone (config('app.timezone')). If a task needs to run at a specific local time regardless of where it's deployed, set the timezone per task — the timestamp converter is useful for checking exactly what a given time looks like in UTC versus your local zone:

Schedule::command('reports:generate')
    ->timezone('Europe/London')
    ->dailyAt('09:00');

A single default can also be set for every scheduled task via schedule_timezone in config/app.php, which avoids repeating timezone() on each call. Watch for daylight saving transitions on servers not running UTC — a task scheduled with dailyAt() can appear to skip or double-fire around the clock change unless the timezone is set explicitly.

Maintenance mode and failures

Scheduled tasks are skipped by default while the application is in maintenance mode, since most sites don't want background jobs mutating data mid-deploy. A task that must still run — a health check, for instance — can opt out of that behaviour:

Schedule::command('healthcheck:ping')
    ->everyMinute()
    ->evenInMaintenanceMode();

To be notified when something goes wrong, chain a callback onto the task itself rather than relying on someone to notice a silent failure:

Schedule::command('reports:generate')
    ->daily()
    ->onSuccess(fn () => Log::info('Report generated'))
    ->onFailure(fn () => Notification::route('mail', 'ops@example.com')
        ->notify(new ScheduledTaskFailed('reports:generate')));

See the mail and notifications guide for more on Notification::route() and building the notification class itself.

External monitoring and output

Rather than only reacting to failure from inside the app, a task can ping an external monitoring service (like healthchecks.io or Cronitor) on start, success, or failure — useful because it also catches the case where the task doesn't run at all, which onFailure() can never detect on its own:

Schedule::command('reports:generate')
    ->daily()
    ->pingBefore($startUrl)
    ->thenPing($completeUrl)
    ->pingOnSuccess($successUrl)
    ->pingOnFailure($failureUrl);

For debugging what a scheduled command actually printed, redirect its output to a file rather than digging through cron's own mail delivery (which is often not configured at all on a fresh server):

Schedule::command('reports:generate')
    ->daily()
    ->sendOutputTo(storage_path('logs/reports.log'))     // overwritten each run
    ->appendOutputTo(storage_path('logs/reports.log'))   // or appended
    ->emailOutputTo('ops@example.com');                  // only sent if output exists