Backend

Laravel Task Scheduling: Cron Jobs Made Easy

Mayur Dabhi
Mayur Dabhi
May 1, 2026
14 min read

Every production web application eventually needs tasks that run automatically on a schedule — sending weekly digest emails, pruning expired tokens, generating nightly reports, syncing data from external APIs. Traditionally, this meant wrestling with raw cron syntax and managing separate cron entries for every server. Laravel's task scheduler eliminates that complexity entirely: you define all your scheduled tasks in expressive PHP code, and a single cron entry does the rest. The result is automated jobs that are version-controlled, readable, and testable.

Why Laravel Scheduler?

Raw cron files are scattered across servers and invisible in your codebase. Laravel's scheduler centralises everything in app/Console/Kernel.php, so every scheduled task is tracked in version control, visible to the whole team, and testable locally without touching server cron.

How the Laravel Scheduler Works

The scheduler's architecture is elegantly simple. A single system cron job fires the Laravel scheduler every minute. Laravel then evaluates which of your defined tasks are due and runs them — all from within the framework's context, with access to your models, services, queues, and configuration.

System Cron Every minute php artisan schedule:run Kernel::schedule() Evaluates tasks Due Tasks Command Closure Shell command One cron entry drives all scheduled tasks in your application

Laravel Scheduler: one cron entry, unlimited scheduled tasks

The only cron entry you ever need to add to your server is:

Server Crontab (crontab -e)
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

Everything else is defined in PHP. This is the core insight that makes Laravel's scheduler so powerful — cron complexity is replaced by readable, chainable PHP methods.

Defining Your First Scheduled Task

All scheduled tasks are registered in the schedule method of app/Console/Kernel.php. Laravel gives you three primary ways to define what runs: an Artisan command, a shell command, or a PHP closure.

app/Console/Kernel.php
<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    protected function schedule(Schedule $schedule): void
    {
        // Run an Artisan command
        $schedule->command('emails:send-digest')->daily();

        // Run a shell command
        $schedule->exec('node /home/app/scripts/sync.js')->hourly();

        // Run a PHP closure
        $schedule->call(function () {
            \DB::table('activity_log')
                ->where('created_at', '<', now()->subDays(30))
                ->delete();
        })->weekly();

        // Run an invokable class
        $schedule->call(new GenerateMonthlyReport)->monthlyOn(1, '08:00');
    }
}

Creating a Custom Artisan Command to Schedule

Schedulable tasks are almost always best expressed as dedicated Artisan commands — they're testable in isolation, have a clear name, and can be triggered manually during debugging.

1

Generate the command

Use Artisan's make:command to scaffold a new command class with the correct structure.

Terminal
php artisan make:command SendWeeklyDigest
2

Implement the command logic

Define the signature, description, and the handle() method that performs the actual work.

app/Console/Commands/SendWeeklyDigest.php
<?php

namespace App\Console\Commands;

use App\Models\User;
use App\Mail\WeeklyDigest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;

class SendWeeklyDigest extends Command
{
    protected $signature = 'emails:send-digest
                            {--dry-run : Preview recipients without sending}';

    protected $description = 'Send weekly digest emails to all active subscribers';

    public function handle(): int
    {
        $users = User::where('subscribed', true)
                     ->where('active', true)
                     ->get();

        if ($this->option('dry-run')) {
            $this->info("Would send to {$users->count()} users.");
            return self::SUCCESS;
        }

        $bar = $this->output->createProgressBar($users->count());

        foreach ($users as $user) {
            Mail::to($user)->queue(new WeeklyDigest($user));
            $bar->advance();
        }

        $bar->finish();
        $this->newLine();
        $this->info("Digest queued for {$users->count()} subscribers.");

        return self::SUCCESS;
    }
}
3

Register it in the scheduler

Add the command to Kernel.php with your desired frequency.

app/Console/Kernel.php — schedule()
$schedule->command('emails:send-digest')
         ->weeklyOn(1, '08:00')  // Every Monday at 8:00 AM
         ->timezone('America/New_York')
         ->withoutOverlapping()
         ->onSuccess(function () {
             Log::info('Weekly digest sent successfully.');
         });

Scheduling Frequencies

Laravel provides an extensive set of frequency methods covering everything from once a minute to once a year. Here are the most commonly used options:

Method Frequency Cron Equivalent
->everyMinute() Every minute * * * * *
->everyFiveMinutes() Every 5 minutes */5 * * * *
->everyThirtyMinutes() Every 30 minutes 0,30 * * * *
->hourly() Every hour at :00 0 * * * *
->hourlyAt(17) Every hour at :17 17 * * * *
->daily() Daily at midnight 0 0 * * *
->dailyAt('13:00') Daily at 1:00 PM 0 13 * * *
->weeklyOn(1, '08:00') Monday at 8:00 AM 0 8 * * 1
->monthly() 1st of month at midnight 0 0 1 * *
->monthlyOn(15, '10:00') 15th of month at 10 AM 0 10 15 * *
->quarterly() First day of each quarter 0 0 1 1-12/3 *
->yearly() January 1st at midnight 0 0 1 1 *
->cron('30 9 * * 1-5') Custom cron expression Weekdays at 9:30 AM

Conditional Scheduling with Constraints

Sometimes a task should only run under certain conditions — only on weekdays, only when a feature flag is enabled, or only when a queue is not backed up. Laravel's when() and skip() methods handle these cases cleanly:

Conditional Scheduling Examples
// Only run on weekdays
$schedule->command('reports:generate')
         ->daily()
         ->weekdays();

// Only run when a condition is true
$schedule->command('sync:external-data')
         ->hourly()
         ->when(fn() => config('features.external_sync_enabled'));

// Skip when condition is true
$schedule->command('cache:warm')
         ->everyFiveMinutes()
         ->skip(fn() => app()->isDownForMaintenance());

// Only run between 6 AM and 10 PM
$schedule->command('notifications:send-push')
         ->everyFifteenMinutes()
         ->between('06:00', '22:00');

// Run on specific environments
$schedule->command('analytics:process')
         ->daily()
         ->environments(['production']);
Timezone Support

By default, scheduled tasks run in your application's timezone. You can override this per-task using ->timezone('Europe/London'). This is critical for tasks like morning reports that must fire at the correct local time regardless of server timezone.

Preventing Task Overlap

A common production problem: a scheduled task takes longer than its interval. The next fire starts while the previous one is still running, creating duplicate operations, database deadlocks, or data corruption. Laravel's withoutOverlapping() solves this with an atomic mutex lock.

Overlap Prevention
// Default: mutex expires after 24 hours if task crashes
$schedule->command('import:products')
         ->everyFiveMinutes()
         ->withoutOverlapping();

// Custom expiry: release mutex after 10 minutes
$schedule->command('import:products')
         ->everyFiveMinutes()
         ->withoutOverlapping(10);

// Run in the background (non-blocking for the scheduler process)
$schedule->command('reports:generate-all')
         ->daily()
         ->runInBackground();

// Combine: background + no overlap
$schedule->command('sync:inventory')
         ->everyMinute()
         ->withoutOverlapping()
         ->runInBackground();
Mutex Storage

withoutOverlapping() requires a cache driver that supports atomic locks — Redis, Memcached, or DynamoDB. It will NOT work correctly with the file or array cache drivers in multi-server setups. Always use Redis in production.

Without withoutOverlapping() Run #1 (long task) Run #2 (overlap!) Run #3 (overlap!) — Duplicate work, data corruption With withoutOverlapping() Run #1 (acquires lock) skipped skipped Run #4 (lock released) — Safe

withoutOverlapping() prevents concurrent task execution using a distributed mutex

Output Handling

By default, scheduled commands discard all output. In production you want to capture output for debugging, send it by email when errors occur, or append it to log files. Laravel gives you precise control.

// Overwrite log file each run
$schedule->command('import:products')
         ->daily()
         ->sendOutputTo(storage_path('logs/import.log'));

// Append to existing log file
$schedule->command('import:products')
         ->daily()
         ->appendOutputTo(storage_path('logs/import.log'));

// Combine stderr and stdout
$schedule->exec('backup.sh')
         ->daily()
         ->appendOutputTo(storage_path('logs/backup.log'));
// Email output only when there's content (errors/warnings)
$schedule->command('cleanup:temp-files')
         ->daily()
         ->emailWrittenOutputTo('admin@example.com');

// Always email output, even when empty
$schedule->command('reports:generate')
         ->monthly()
         ->emailOutputTo('manager@example.com');

// Email to multiple recipients
$schedule->command('inventory:sync')
         ->daily()
         ->emailWrittenOutputTo([
             'devops@example.com',
             'support@example.com',
         ]);
// Ping a URL when the task starts (e.g., Healthchecks.io)
$schedule->command('backup:run')
         ->daily()
         ->pingBefore('https://hc-ping.com/YOUR-UUID/start')
         ->thenPing('https://hc-ping.com/YOUR-UUID');

// Ping only on success
$schedule->command('sync:data')
         ->hourly()
         ->thenPingIf(true, 'https://hc-ping.com/YOUR-UUID');

// Ping on failure
$schedule->command('reports:generate')
         ->daily()
         ->pingOnFailure('https://hc-ping.com/YOUR-UUID/fail');
$schedule->command('emails:send-digest')
         ->weekly()
         ->before(function () {
             Log::info('Starting weekly digest run');
         })
         ->after(function () {
             Log::info('Weekly digest run complete');
         })
         ->onSuccess(function (Stringable $output) {
             // $output contains command stdout
             Cache::put('last_digest_output', $output, 3600);
         })
         ->onFailure(function (Stringable $output) {
             // Alert the team
             Notification::route('mail', 'devops@example.com')
                 ->notify(new SchedulerFailedAlert($output));
         });

Real-World Scheduling Patterns

Let's look at practical scheduling setups you'll encounter in actual production applications.

Database Cleanup Jobs

app/Console/Commands/PruneExpiredData.php
<?php

namespace App\Console\Commands;

use App\Models\PasswordReset;
use App\Models\PersonalAccessToken;
use App\Models\ActivityLog;
use Illuminate\Console\Command;

class PruneExpiredData extends Command
{
    protected $signature = 'db:prune-expired';
    protected $description = 'Remove expired tokens, resets, and old activity logs';

    public function handle(): int
    {
        $deleted = 0;

        // Expired password resets (older than 60 minutes)
        $deleted += PasswordReset::where('created_at', '<', now()->subHour())->delete();

        // Expired personal access tokens
        $deleted += PersonalAccessToken::where('expires_at', '<', now())->delete();

        // Activity logs older than 90 days
        $deleted += ActivityLog::where('created_at', '<', now()->subDays(90))->delete();

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

        return self::SUCCESS;
    }
}

// In Kernel.php
$schedule->command('db:prune-expired')
         ->daily()
         ->withoutOverlapping()
         ->appendOutputTo(storage_path('logs/pruning.log'));

Nightly Report Generation

Nightly Reports Pattern
// In Kernel.php schedule()
$schedule->command('reports:daily-sales')
         ->dailyAt('02:00')                       // Run at 2 AM
         ->timezone('UTC')
         ->withoutOverlapping()
         ->runInBackground()
         ->onFailure(function () {
             // Alert Slack or email
             Log::critical('Daily sales report generation failed!');
         });

$schedule->command('reports:generate-sitemap')
         ->weekly()
         ->onSuccess(function () {
             // Ping Google to re-crawl
             Http::get('https://www.google.com/ping?sitemap='
                 . urlencode(config('app.url') . '/sitemap.xml'));
         });

Queue Health Monitoring

Queue Monitor Command
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Queue;

class MonitorQueueHealth extends Command
{
    protected $signature = 'queue:health-check
                            {--threshold=1000 : Alert if more than N jobs are waiting}';
    protected $description = 'Alert when the default queue has too many pending jobs';

    public function handle(): int
    {
        $size = Queue::size('default');
        $threshold = (int) $this->option('threshold');

        if ($size > $threshold) {
            $this->error("ALERT: Queue has {$size} jobs (threshold: {$threshold})");
            return self::FAILURE;
        }

        $this->info("Queue healthy: {$size} jobs pending.");
        return self::SUCCESS;
    }
}

// In Kernel.php
$schedule->command('queue:health-check --threshold=500')
         ->everyFiveMinutes()
         ->emailWrittenOutputTo('devops@example.com'); // Only emails on FAILURE output

Running the Scheduler in Production

Getting the scheduler running in production correctly requires attention to the server setup, process management, and monitoring strategy.

Setting Up the System Cron

Terminal — open crontab for the web user
# Open crontab as the user that runs PHP (e.g., www-data or forge)
sudo -u www-data crontab -e

# Add this single line
* * * * * cd /var/www/your-app && php artisan schedule:run >> /dev/null 2>&1

# Verify crontab was saved
sudo -u www-data crontab -l

Using Laravel Scheduler with Supervisor (Daemon Mode)

Laravel 10+ introduced schedule:work, a long-running daemon that runs the scheduler every minute without needing a system cron at all. This is ideal for local development and some PaaS platforms.

Supervisor Config — /etc/supervisor/conf.d/laravel-scheduler.conf
[program:laravel-scheduler]
process_name=%(program_name)s
command=php /var/www/your-app/artisan schedule:work
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/scheduler.log
stopwaitsecs=60
Terminal — Apply Supervisor Config
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-scheduler:*
sudo supervisorctl status

Listing All Scheduled Tasks

Use the built-in schedule:list command to see every registered task, its next run time, and its cron expression — invaluable for debugging in production.

Terminal
php artisan schedule:list

# Output example:
#  Command                        Interval   Next Due
#  emails:send-digest             Weekly     2026-05-04 08:00:00
#  db:prune-expired               Daily      2026-05-02 00:00:00
#  reports:daily-sales            Daily      2026-05-02 02:00:00
#  queue:health-check --threshold Every 5m   2026-05-01 12:05:00

# Run a specific task immediately (bypassing schedule)
php artisan schedule:run --verbose

Testing Scheduled Tasks

Scheduled tasks are often undertested because they're hard to trigger in a controlled way. Laravel provides dedicated testing helpers for the scheduler.

tests/Feature/SchedulerTest.php
<?php

namespace Tests\Feature;

use Illuminate\Console\Scheduling\Schedule;
use Tests\TestCase;

class SchedulerTest extends TestCase
{
    public function test_digest_command_is_scheduled_weekly(): void
    {
        $schedule = app(Schedule::class);

        $events = collect($schedule->events())
            ->filter(fn($event) => str_contains($event->command, 'emails:send-digest'));

        $this->assertCount(1, $events, 'send-digest should be scheduled exactly once');

        $event = $events->first();
        $this->assertTrue($event->isDue(app()), 'Task should be due now for testing');
    }

    public function test_digest_command_executes_successfully(): void
    {
        // Test the command itself in isolation
        $this->artisan('emails:send-digest --dry-run')
             ->assertExitCode(0)
             ->expectsOutputToContain('Would send to');
    }

    public function test_prune_command_deletes_old_records(): void
    {
        // Create records that should be deleted
        ActivityLog::factory()->count(5)->create([
            'created_at' => now()->subDays(100),
        ]);

        $this->artisan('db:prune-expired')->assertExitCode(0);

        $this->assertDatabaseCount('activity_logs', 0);
    }
}

Scheduler vs Queue Workers: When to Use Each

Criteria Task Scheduler Queue Worker
Trigger Time-based (cron) Event-based (job dispatched)
Example Nightly report at 2 AM Send email after registration
Retries Not automatic — reschedules next cycle Configurable automatic retries
Concurrency Use withoutOverlapping() Multiple workers by default
Monitoring schedule:list, log output Horizon dashboard, queue:monitor
Best for Batch operations, maintenance, reports User-triggered async work, notifications

Often used together: the scheduler fires a command that dispatches queue jobs, combining time-based triggering with robust job retry and concurrency handling.

Key Takeaways

Laravel's task scheduler trades raw cron complexity for expressive PHP that lives in your codebase. Here's what to take away:

Best Practices Summary

  • One cron entry only: The single * * * * * php artisan schedule:run line drives every task.
  • Always use withoutOverlapping() for tasks that could run longer than their interval — requires Redis.
  • Wrap business logic in Artisan commands, not closures — commands are independently testable and rerunnable.
  • Use runInBackground() for long tasks so they don't block the scheduler process.
  • Capture output via appendOutputTo() or emailWrittenOutputTo() so failures don't go unnoticed.
  • Use lifecycle hooks (onSuccess, onFailure) to integrate with Slack, PagerDuty, or Healthchecks.io.
  • Run php artisan schedule:list after deployments to verify task registration.
"The best production incident is the one that never happens because your cleanup job ran on schedule."
— A DevOps engineer at 3 AM

Task scheduling is one of those features that seems minor until you need it — and then you wonder how you lived without it. With Laravel's scheduler, what used to require a patchwork of separate cron scripts and server documentation becomes a clean, version-controlled definition that any developer on your team can read, modify, and test in minutes.

Laravel Scheduling Cron PHP Automation Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.