Back to Blog
Laravel22 min read

Laravel Task Scheduling: The Complete Cron Job Guide

Laravel's task scheduler lets you define your entire cron job schedule fluently in PHP code instead of managing individual crontab entries on the server. You add one crontab entry that runs schedule:run every minute, and Laravel handles the rest. This guide covers everything from basic setup to advanced patterns like overlap prevention, multi-server coordination, output handling, and production monitoring with CronJobPro.

How Laravel Task Scheduling Works

Traditional server cron management requires you to add a separate crontab entry for every scheduled task. With 10 tasks, you have 10 crontab lines to manage, deploy, and keep in sync across environments. Laravel replaces this with a single approach: define all your scheduled tasks in PHP code, and let the framework handle timing and execution.

The scheduling logic lives in your application's app/Console/Kernel.php file (or in Laravel 11+, the routes/console.php file). The schedule() method receives a Schedule instance where you define what to run and when:

// app/Console/Kernel.php (Laravel 10 and earlier)

protected function schedule(Schedule $schedule): void
{
    $schedule->command('reports:generate')->daily();
    $schedule->command('emails:send-digest')->weeklyOn(1, '8:00');
    $schedule->command('cache:clear-expired')->hourly();
    $schedule->job(new ProcessPendingOrders)->everyFiveMinutes();
}

In Laravel 11+, you can also define schedules in routes/console.php using the Schedule facade:

// routes/console.php (Laravel 11+)

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:generate')->daily();
Schedule::command('emails:send-digest')->weeklyOn(1, '8:00');
Schedule::job(new ProcessPendingOrders)->everyFiveMinutes();

Every minute, the system cron calls php artisan schedule:run. This Artisan command evaluates every task defined in your schedule, checks whether each one is due based on the current time, and executes the ones that match. Tasks that are not due are silently skipped. The entire evaluation typically completes in milliseconds.

This design has several important advantages: your schedule is version-controlled alongside your application code, you can use PHP logic (environment checks, feature flags) to conditionally include tasks, and deploying schedule changes is just a code deployment — no SSH access to edit crontab required.

Setting Up the System Cron Entry

Laravel's scheduler requires exactly one cron expression entry in your server's crontab. This is the famous single-line setup that runs the scheduler every minute:

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

Let's break down each part of this cron job:

PartMeaning
* * * * *Run every minute of every hour of every day
cd /path-to-your-projectChange to your Laravel project root directory
php artisan schedule:runExecute the Laravel scheduler, which evaluates all defined tasks
>> /dev/null 2>&1Suppress all output (stdout and stderr)

Adding the Crontab Entry

# Open the crontab editor for the current user
crontab -e

# Add this line (replace the path with your actual project path):
* * * * * cd /var/www/myapp && php artisan schedule:run >> /dev/null 2>&1

# Save and exit. Verify it was saved:
crontab -l

Important

Make sure the crontab belongs to the same user that owns your Laravel files (typically www-data on Ubuntu/Debian or the deployment user). Running the scheduler as root can create files owned by root in your storage/ directory, causing permission errors for the web server.

Local Development Alternative

During local development, you do not need to set up a crontab. Laravel provides two commands that run the scheduler in the foreground:

# Run the scheduler continuously in the foreground (runs every minute)
php artisan schedule:work

# Run a specific scheduled task interactively for testing
php artisan schedule:test

The schedule:work command stays running and invokes schedule:run every 60 seconds. It is the easiest way to test your scheduled tasks locally. The schedule:test command (available since Laravel 9) displays a list of all scheduled tasks and lets you select one to execute immediately.

Scheduling Methods

Laravel provides four primary methods for defining what to schedule. Each method handles a different type of task:

1. Artisan Commands with command()

The most common method. Schedule any Artisan command by its signature. You can pass arguments and options just like you would on the command line:

// Schedule an Artisan command
$schedule->command('emails:send --force')->daily();

// With arguments
$schedule->command('reports:generate', ['--type' => 'monthly'])->monthlyOn(1, '02:00');

// Using the command class directly
$schedule->command(SendEmails::class, ['--queue' => 'high'])->everyFiveMinutes();

2. Closures with call()

For simple tasks that do not warrant a full Artisan command, you can schedule a closure directly. The closure has access to Laravel's service container via dependency injection:

// Schedule a closure
$schedule->call(function () {
    DB::table('recent_users')->delete();
})->daily();

// With a named reference for monitoring
$schedule->call(function () {
    Cache::flush();
})->weekly()->name('cache-flush')->withoutOverlapping();

Tip

When using closures with withoutOverlapping() or onOneServer(), you must give the task a name using ->name(). Artisan commands get their name automatically from the command signature.

3. Queued Jobs with job()

Dispatch a queued job on a schedule. This is the best approach for long-running tasks because the scheduler immediately dispatches the job to the queue and moves on, keeping the schedule:run process fast:

// Dispatch a job to the default queue
$schedule->job(new ProcessPendingOrders)->everyFiveMinutes();

// Dispatch to a specific queue and connection
$schedule->job(new GenerateReport, 'reports', 'redis')->dailyAt('03:00');

// With a custom queue connection
$schedule->job(new SyncInventory, 'sync')->everyTenMinutes();

4. Shell Commands with exec()

Execute arbitrary shell commands. Useful for running non-PHP scripts, system maintenance tasks, or external tools:

// Run a shell command
$schedule->exec('node /opt/scripts/process-queue.js')->everyFiveMinutes();

// Database backup via shell
$schedule->exec('mysqldump -u root mydb > /backups/db-$(date +\%Y\%m\%d).sql')
    ->dailyAt('02:00');

// Run a Python script
$schedule->exec('/usr/bin/python3 /opt/ml/retrain-model.py')->weeklyOn(0, '04:00');

Frequency Options

Laravel provides a rich set of fluent methods for defining how often a task should run. These map to cron expressions behind the scenes but are far more readable. Here is the complete reference:

Time-Based Frequencies

MethodFrequencyCron Equivalent
->everyMinute()Every minute* * * * *
->everyTwoMinutes()Every 2 minutes*/2 * * * *
->everyThreeMinutes()Every 3 minutes*/3 * * * *
->everyFourMinutes()Every 4 minutes*/4 * * * *
->everyFiveMinutes()Every 5 minutes*/5 * * * *
->everyTenMinutes()Every 10 minutes*/10 * * * *
->everyFifteenMinutes()Every 15 minutes*/15 * * * *
->everyThirtyMinutes()Every 30 minutes0,30 * * * *
->hourly()Every hour at :000 * * * *
->hourlyAt(17)Every hour at :1717 * * * *
->everyOddHour()Every odd hour0 1-23/2 * * *
->everyTwoHours()Every 2 hours0 */2 * * *
->daily()Daily at midnight0 0 * * *
->dailyAt('13:00')Daily at 1:00 PM0 13 * * *
->twiceDaily(1, 13)At 1:00 AM and 1:00 PM0 1,13 * * *
->weekly()Sunday at midnight0 0 * * 0
->weeklyOn(1, '8:00')Monday at 8:00 AM0 8 * * 1
->monthly()1st of month at midnight0 0 1 * *
->monthlyOn(4, '15:00')4th of month at 3:00 PM0 15 4 * *
->quarterly()1st of quarter at midnight0 0 1 1-12/3 *
->yearly()January 1st at midnight0 0 1 1 *

Custom Cron Expressions

For schedules that do not fit the built-in methods, use the cron() method with a raw cron expression. Use our cron expression generator to build the exact expression you need:

// Custom cron expression: every weekday at 9:30 AM
$schedule->command('reports:daily')->cron('30 9 * * 1-5');

// Every 45 minutes
$schedule->command('sync:data')->cron('*/45 * * * *');

// At 6 PM on the last day of every month (use a constraint instead)
$schedule->command('reports:monthly')
    ->monthlyOn(28, '18:00')
    ->when(function () {
        return now()->isLastOfMonth();
    });

Day Constraints

You can chain day-of-week constraints onto any frequency method to further restrict when a task runs:

// Only on weekdays
$schedule->command('sync:crm')->hourly()->weekdays();

// Only on weekends
$schedule->command('cleanup:temp')->daily()->weekends();

// Only on specific days
$schedule->command('reports:sales')->daily()->mondays()->wednesdays()->fridays();

// Between specific hours
$schedule->command('process:orders')
    ->everyFiveMinutes()
    ->between('08:00', '22:00');

// NOT between specific hours (skip overnight)
$schedule->command('send:notifications')
    ->everyFiveMinutes()
    ->unlessBetween('23:00', '06:00');

Preventing Task Overlaps

A common issue with scheduled tasks: what happens when a task takes longer to execute than the interval between runs? If your task runs every 5 minutes but occasionally takes 7 minutes to complete, you end up with two instances running simultaneously. This can cause data corruption, duplicate processing, or resource exhaustion.

withoutOverlapping()

The withoutOverlapping() method ensures only one instance of the task runs at a time. If the previous run is still executing, the new invocation is skipped:

// If still running from the previous invocation, skip this run
$schedule->command('process:large-dataset')
    ->everyFiveMinutes()
    ->withoutOverlapping();

// Optional: set a lock expiration time (in minutes)
// Default is 24 hours. Useful if a task crashes and leaves a stale lock.
$schedule->command('process:large-dataset')
    ->everyFiveMinutes()
    ->withoutOverlapping(30);  // Lock expires after 30 minutes

Under the hood, Laravel uses a cache-based mutex lock. When the task starts, it acquires a lock in the configured cache store. When it finishes, the lock is released. If the task crashes without releasing the lock, it will be automatically released after the expiration time (default: 1440 minutes / 24 hours).

onOneServer()

In load-balanced environments where multiple servers run the same Laravel application, every server will have its own crontab entry and will try to execute every scheduled task. The onOneServer() method ensures the task runs on only one server:

// Only run on one server in the cluster
$schedule->command('reports:generate')
    ->daily()
    ->onOneServer();

// Combine with withoutOverlapping for maximum safety
$schedule->command('process:payments')
    ->everyFiveMinutes()
    ->withoutOverlapping()
    ->onOneServer();

Important

The onOneServer() method requires a centralized cache driver that all servers can access — typically Redis or Memcached. The file and array cache drivers are local to each server and will not prevent cross-server execution.

Running Tasks in Background

By default, all tasks scheduled within the same schedule:run invocation run sequentially. If you have three tasks due at the same time, the second waits for the first to finish, and the third waits for the second. The runInBackground() method changes this behavior:

// Run this task in a background process so it doesn't block others
$schedule->command('analytics:aggregate')
    ->daily()
    ->runInBackground();

// Combine with other modifiers
$schedule->command('reports:generate')
    ->dailyAt('03:00')
    ->runInBackground()
    ->withoutOverlapping()
    ->appendOutputTo(storage_path('logs/reports.log'));

When a task runs in the background, Laravel starts it as a separate OS process and immediately moves on to the next task. This is important for long-running tasks that would otherwise delay subsequent tasks in the schedule. However, be aware that background tasks have implications for monitoring: the schedule:run command will report success as soon as the background process is started, not when it finishes.

Tip

The runInBackground() method only works with command() and exec() methods. Closures (call()) and jobs (job()) always run in the foreground. For truly async execution of closures, dispatch a queued job instead.

Task Output

By default, the output of scheduled tasks is discarded. Laravel provides several methods to capture, store, or email the output of your scheduled commands:

// Write output to a file (overwrites each run)
$schedule->command('reports:generate')
    ->daily()
    ->sendOutputTo(storage_path('logs/report-output.log'));

// Append output to a file (preserves history)
$schedule->command('reports:generate')
    ->daily()
    ->appendOutputTo(storage_path('logs/report-output.log'));

// Email the output to an address
$schedule->command('reports:generate')
    ->daily()
    ->emailOutputTo('admin@example.com');

// Email output ONLY when the command fails (non-zero exit code)
$schedule->command('reports:generate')
    ->daily()
    ->emailOutputOnFailure('admin@example.com');

// Combine: save to file AND email on failure
$schedule->command('backup:run')
    ->dailyAt('02:00')
    ->appendOutputTo(storage_path('logs/backup.log'))
    ->emailOutputOnFailure('ops@example.com');

The emailOutputTo() and emailOutputOnFailure() methods require that your Laravel mail configuration is working. They use whatever mail driver is configured in config/mail.php.

Maintenance Mode

When you put your Laravel application into maintenance mode with php artisan down, scheduled tasks do not execute by default. This is usually the correct behavior — you do not want tasks modifying data while you are running migrations or deploying code. But some tasks are critical enough that they must run regardless:

// This task runs even when the application is in maintenance mode
$schedule->command('monitor:health-check')
    ->everyMinute()
    ->evenInMaintenanceMode();

// Useful for tasks that should never be interrupted
$schedule->command('backups:run')
    ->dailyAt('02:00')
    ->evenInMaintenanceMode();

Use evenInMaintenanceMode() sparingly and only for tasks that are truly critical, such as health checks, backups, or monitoring heartbeats. Tasks that modify application data should respect maintenance mode.

Hooks: before, after, onSuccess, onFailure

Laravel provides lifecycle hooks that let you execute code before and after each scheduled task. These are powerful for logging, notifications, and integrating with external monitoring services:

$schedule->command('reports:generate')
    ->daily()
    ->before(function () {
        // Runs BEFORE the task starts
        Log::info('Starting report generation...');
    })
    ->after(function () {
        // Runs AFTER the task finishes (regardless of success/failure)
        Log::info('Report generation finished.');
    })
    ->onSuccess(function () {
        // Runs ONLY if the task succeeded (exit code 0)
        Notification::send($admins, new ReportReady());
    })
    ->onFailure(function () {
        // Runs ONLY if the task failed (non-zero exit code)
        Notification::send($admins, new ReportFailed());
    });

Ping URLs (Heartbeats)

Laravel can automatically ping a URL before or after task execution. This is the foundation for integrating with monitoring services like CronJobPro:

$schedule->command('process:orders')
    ->everyFiveMinutes()
    ->pingBefore('https://cronjobpro.com/api/heartbeat/abc123/start')
    ->thenPing('https://cronjobpro.com/api/heartbeat/abc123')
    ->pingOnSuccess('https://cronjobpro.com/api/heartbeat/abc123/success')
    ->pingOnFailure('https://cronjobpro.com/api/heartbeat/abc123/fail');

The ping methods send a simple HTTP GET request to the specified URL. They are non-blocking and will not delay your task if the monitoring service is slow to respond. If the ping request fails (network error, timeout), it is silently ignored so it does not affect your task execution.

Monitoring with CronJobPro

Laravel's single-crontab-entry design is elegant, but it creates a single point of failure. If the system cron daemon stops, if the server goes down, or if schedule:run starts failing silently, every scheduled task in your application stops running. You get no notification. No alert. Nothing — until someone notices that reports are not being generated, backups are not happening, or emails are not being sent.

CronJobPro adds a monitoring layer on top of your Laravel scheduler. There are two complementary approaches:

Approach 1: External Heartbeat Monitoring

Use Laravel's built-in thenPing() method to send a heartbeat to CronJobPro after each critical task completes. If CronJobPro does not receive the expected ping within the configured window, it alerts you immediately:

// In your schedule definition
$schedule->command('backup:database')
    ->dailyAt('02:00')
    ->thenPing('https://cronjobpro.com/api/heartbeat/YOUR_MONITOR_ID');

$schedule->command('process:payments')
    ->everyFiveMinutes()
    ->withoutOverlapping()
    ->pingOnSuccess('https://cronjobpro.com/api/heartbeat/YOUR_PAYMENT_MONITOR_ID')
    ->pingOnFailure('https://cronjobpro.com/api/heartbeat/YOUR_PAYMENT_MONITOR_ID/fail');

Approach 2: Schedule:run Wrapper

Monitor the schedule:run process itself. Replace the crontab's output suppression with a CronJobPro heartbeat URL to verify the scheduler is actually running every minute:

# Instead of suppressing output, ping CronJobPro after each run
* * * * * cd /var/www/myapp && php artisan schedule:run && curl -s https://cronjobpro.com/api/heartbeat/SCHEDULER_MONITOR_ID > /dev/null

This approach gives you a single dashboard where you can see: (1) whether the scheduler itself is running, (2) the output and exit code of each run, and (3) instant alerts via email or webhook when something breaks. Learn more about cron job monitoring best practices.

Common Patterns

Here are four production-ready patterns that cover the most common use cases for Laravel scheduled tasks:

Database Backup

// Daily database backup with rotation
$schedule->exec('mysqldump -u $DB_USERNAME -p$DB_PASSWORD $DB_DATABASE | gzip > /backups/db-$(date +\%Y\%m\%d-\%H\%M).sql.gz')
    ->dailyAt('02:00')
    ->withoutOverlapping()
    ->onOneServer()
    ->appendOutputTo(storage_path('logs/backup.log'))
    ->emailOutputOnFailure('ops@example.com')
    ->thenPing('https://cronjobpro.com/api/heartbeat/BACKUP_MONITOR');

// Clean up backups older than 30 days
$schedule->exec('find /backups -name "db-*.sql.gz" -mtime +30 -delete')
    ->dailyAt('03:00');

Queue Health Check

// Restart queue workers if they've been running too long (memory leaks)
$schedule->command('queue:restart')->hourly();

// Monitor queue size and alert if backlog grows
$schedule->call(function () {
    $size = Queue::size('default');
    if ($size > 1000) {
        Log::warning("Queue backlog alert: {$size} jobs pending");
        Notification::route('mail', 'ops@example.com')
            ->notify(new QueueBacklogAlert($size));
    }
})->everyFiveMinutes()->name('queue-health-check');

Cache Warming and Cleanup

// Clear expired cache entries
$schedule->command('cache:prune-stale-tags')->hourly();

// Warm critical caches during off-peak hours
$schedule->command('cache:warm --pages=homepage,products,categories')
    ->dailyAt('04:00')
    ->runInBackground();

// Rebuild sitemap
$schedule->command('sitemap:generate')
    ->dailyAt('05:00')
    ->appendOutputTo(storage_path('logs/sitemap.log'));

Report Generation

// Generate daily sales report and email it
$schedule->command('reports:daily-sales')
    ->dailyAt('08:00')
    ->weekdays()
    ->timezone('America/New_York')
    ->onSuccess(function () {
        Mail::to('sales@example.com')->send(new DailySalesReport());
    })
    ->onFailure(function () {
        Log::error('Daily sales report generation failed');
    });

// Monthly financial summary
$schedule->command('reports:monthly-summary')
    ->monthlyOn(1, '09:00')
    ->withoutOverlapping()
    ->emailOutputOnFailure('cfo@example.com');

Troubleshooting

When scheduled tasks do not run as expected, here is a systematic approach to diagnosing the issue. For general cron troubleshooting beyond Laravel, see our cron job not running troubleshooting guide.

schedule:run Not Executing Any Tasks

Symptom: You run php artisan schedule:run manually and it outputs "No scheduled commands are ready to run" even though you expect tasks to be due.

# Step 1: List all scheduled tasks and their next run times
php artisan schedule:list

# Step 2: Check if the Kernel schedule() method is being loaded
php artisan schedule:list --verbose

# Step 3: Verify the application environment
php artisan env

# Step 4: Check for environment-specific schedules
# Some apps only register tasks in production:
# if (app()->environment('production')) { ... }

# Step 5: Test a specific task
php artisan schedule:test

Timezone Issues

Symptom: Tasks run at unexpected times — usually offset by a few hours. This happens when the server timezone, the PHP timezone, and the Laravel application timezone do not match.

# Check the server's system timezone
timedatectl

# Check PHP's timezone
php -r "echo date_default_timezone_get();"

# Check Laravel's configured timezone
php artisan tinker --execute="echo config('app.timezone');"

# Fix: Set the timezone in config/app.php
// 'timezone' => 'America/New_York',

# Or set per-task timezone:
// $schedule->command('reports:daily')->daily()->timezone('Europe/London');

For a deep dive into timezone problems, see our cron job timezone issues guide.

Queue vs Scheduler Confusion

Symptom: You scheduled a job with $schedule->job() but it does not seem to execute its logic. The scheduler reports success, but nothing actually happens.

Cause: The job() method dispatches the job to your queue — it does not execute it inline. If no queue worker is running (php artisan queue:work), the job sits in the queue and never gets processed.

# Check if queue workers are running
ps aux | grep "queue:work"

# Check the queue for pending jobs
php artisan queue:monitor default

# Start a queue worker if none is running
php artisan queue:work --daemon

# Alternative: Use command() instead of job() to run inline
$schedule->command('orders:process')->everyFiveMinutes();

Permission Errors

Symptom: Tasks fail with permission denied errors, or the crontab entry runs but tasks produce no output and no effect.

# Check which user the crontab belongs to
crontab -l

# Compare with the file owner
ls -la /var/www/myapp/storage/logs/

# Fix: Ensure the crontab runs as the correct user
# On Ubuntu/Debian, for the www-data user:
sudo crontab -u www-data -e

# Check if PHP is accessible from the cron environment
which php
# The cron environment has a minimal PATH. Use the full path:
* * * * * cd /var/www/myapp && /usr/bin/php artisan schedule:run >> /dev/null 2>&1

Frequently Asked Questions

What happens to Laravel scheduled tasks if the server reboots?

Laravel's scheduler relies on the system crontab entry that calls schedule:run every minute. On most Linux servers, the cron daemon starts automatically on boot, so scheduled tasks resume without intervention. However, if the server was down for several minutes, any tasks that were due during that window are simply skipped — Laravel does not retroactively run missed tasks. To catch server downtime early, use an external monitoring service like CronJobPro that alerts you when heartbeat pings stop arriving.

What is the difference between Laravel scheduler and Laravel queues?

The scheduler determines when tasks run (on a time-based schedule), while queues determine how tasks run (asynchronously in the background). The scheduler triggers tasks at specific times using cron expressions, whereas queues process jobs that are dispatched by your application code on demand. They complement each other: you can use the scheduler to dispatch jobs to the queue at regular intervals, combining time-based triggering with async execution.

How do I test Laravel scheduled tasks locally?

Laravel provides the schedule:test Artisan command (available since Laravel 9) that lets you select and immediately run any scheduled task from an interactive list. You can also use schedule:work, which runs the scheduler in the foreground and executes due tasks every minute — ideal for local development without setting up a crontab. For unit testing, use the Schedule facade to assert that tasks are scheduled at the expected frequencies.

How do I configure the timezone for Laravel scheduled tasks?

By default, scheduled tasks use the timezone defined in config/app.php (the timezone key). You can override this per task using ->timezone('America/New_York'). You can also set a default scheduler timezone by defining a scheduleTimezone() method in your Console Kernel that returns the desired timezone string. This is useful when your server runs in UTC but you want tasks to follow a local business timezone.

How does Laravel handle scheduled tasks on multiple servers?

By default, if you deploy the same Laravel application on multiple servers — each with its own crontab entry — every server will run every scheduled task, leading to duplicates. Laravel solves this with the ->onOneServer() method, which uses a cache lock (Redis or Memcached) to ensure only one server executes the task. The first server to acquire the lock runs it; the others skip. This requires a centralized cache driver — the file or array drivers will not work for cross-server coordination.

Related Articles

Monitor your Laravel scheduler

One crontab entry powers your entire schedule. If it stops, everything stops. CronJobPro monitors your schedule:run heartbeat, alerts you when tasks fail or go silent, and gives you a full execution history dashboard. Free for up to 5 monitors.