Laravel Task Scheduling: Cron Jobs Made Easy
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.
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.
Laravel Scheduler: one cron entry, unlimited scheduled tasks
The only cron entry you ever need to add to your server is:
* * * * * 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.
<?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.
Generate the command
Use Artisan's make:command to scaffold a new command class with the correct structure.
php artisan make:command SendWeeklyDigest
Implement the command logic
Define the signature, description, and the handle() method that performs the actual work.
<?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;
}
}
Register it in the scheduler
Add the command to Kernel.php with your desired frequency.
$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:
// 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']);
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.
// 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();
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.
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
<?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
// 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
<?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
# 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.
[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
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.
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.
<?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:runline 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()oremailWrittenOutputTo()so failures don't go unnoticed. - Use lifecycle hooks (
onSuccess,onFailure) to integrate with Slack, PagerDuty, or Healthchecks.io. - Run
php artisan schedule:listafter 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.