Overlapping prevention, single-server mode, scheduled maintenance windows, output logging, heartbeat monitoring with Oh Dear, timezone-aware scheduling, and the one gotcha that silently skips your jobs in production — the complete guide to Laravel’s task scheduler beyond ->daily().
Most Laravel developers set up the scheduler once — add the single cron entry, define a few ->daily() commands, confirm they run, and move on. The scheduler becomes infrastructure background noise. Then something happens: a report job runs twice because two servers both picked it up, a cleanup task runs during business hours and locks tables, a command starts producing output that disappears into the void, a job silently stops running for three weeks and nobody notices until a customer asks where their weekly digest went.
Every one of those problems has a solution in the scheduler that’s already available. This post covers the full scheduler API — not the parts the documentation explains well, but the parts that solve the production problems.
The One Entry That Runs Everything
The entire scheduler runs from a single cron entry:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Every minute, schedule:run fires. It evaluates the schedule you’ve defined and runs any commands whose time has come. Commands that aren’t due are skipped in milliseconds. The scheduler handles frequency, overlap prevention, single-server locking, output logging, and before/after hooks.
In Laravel 11+, the schedule is defined in routes/console.php:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:generate-weekly')->weeklyOn(1, '6:00')->timezone('Asia/Kolkata');
Schedule::command('tenants:sync-billing')->hourly()->withoutOverlapping();
Schedule::command('cache:prune-stale')->dailyAt('02:30')->runInBackground();
In Laravel 10 and below, it’s app/Console/Kernel.php:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
$schedule->command('reports:generate-weekly')
->weeklyOn(1, '6:00')
->timezone('Asia/Kolkata');
}
Both work identically. The routes file is the cleaner convention.
The Gotcha That Silently Skips Jobs in Production
The most common production problem with the scheduler: commands silently don’t run because the cron entry isn’t set up correctly or schedule:run is failing silently.
The three versions of this problem:
Problem 1: The cron entry uses the wrong user
# ❌ Root cron — may not have the application's environment variables
* * * * * php /var/www/app/artisan schedule:run
# ✅ Application user cron — has the correct environment
* * * * * www-data cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Running the cron as root means the process doesn’t have the application user’s environment — no .env file loading, potentially wrong PHP binary, wrong working directory. Use the same user that owns the application files.
Problem 2: Output is discarded before you see the error
# ❌ Errors disappear entirely
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
# ✅ Errors go to a log file
* * * * * cd /var/www/app && php artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1
2>&1 redirects stderr to stdout, then >> /dev/null discards both. You never see errors. Point the output at a log file during initial setup, confirm the scheduler is running correctly, then redirect to /dev/null if the noise isn’t useful.
Problem 3: The schedule definition has a typo in the command name
// ❌ Command name doesn't match the registered Artisan command
Schedule::command('report:generate')->daily(); // singular 'report'
// But the command is registered as 'reports:generate' (plural)
// → No error, no output, command never runs
Laravel’s scheduler silently skips commands it can’t find. No exception, no log entry. Test every scheduled command by running php artisan schedule:test (available in Laravel 11+):
php artisan schedule:test
# Lists all scheduled commands and lets you run any of them on demand
# Confirms the command name resolves correctly before it's supposed to run
Or verify by listing all scheduled tasks:
php artisan schedule:list
# Shows every scheduled task, its next run time, and its cron expression
Overlapping Prevention — The Feature You Should Enable by Default
Without withoutOverlapping(), if a command takes longer than its scheduling interval, multiple instances run simultaneously. A daily report that takes 90 minutes is still running when the next daily trigger fires. Now you have two report jobs running in parallel, writing to the same output location, potentially reading inconsistent data.
// ❌ Without overlap prevention — runs again even if still running
Schedule::command('reports:generate-weekly')->weekly();
// ✅ Skip if previous instance still running
Schedule::command('reports:generate-weekly')
->weekly()
->withoutOverlapping();
// With a custom lock expiry — how long to hold the lock before assuming the process died
// Default is 24 hours — override for jobs with known maximum runtime
Schedule::command('tenants:export-all')
->hourly()
->withoutOverlapping(expiresAt: 30); // release lock after 30 minutes
withoutOverlapping() uses a cache lock. If the cache driver is Redis (which it should be in production), the lock is atomic and works correctly across restarts. If the cache driver is file or database, the lock persists on a single server but doesn’t work across multiple servers.
The expiresAt parameter matters. If a process crashes or is killed without releasing the lock, the lock expiry ensures the next run isn’t blocked indefinitely. Set it to the maximum reasonable runtime of the job, not the expected runtime. A report that usually takes 10 minutes but could theoretically take 45 should have expiresAt: 60, not expiresAt: 15.
Single-Server Mode — The Distributed Scheduling Problem
If your application runs on multiple servers, every server runs schedule:run every minute. By default, every server that’s due to run a command will run it. A daily database cleanup on three servers runs three times.
onOneServer() uses a distributed lock to ensure only one server runs the command:
Schedule::command('database:cleanup-expired-tokens')
->daily()
->onOneServer();
Schedule::command('reports:send-weekly-digest')
->weeklyOn(1, '7:00')
->onOneServer()
->withoutOverlapping();
onOneServer() requires a cache driver that supports atomic locks. Redis, Memcached, and DynamoDB support this. File and database cache drivers do not. If you’re on a multi-server deployment without Redis, onOneServer() silently fails to acquire the distributed lock and all servers run the command.
The combination onOneServer() + withoutOverlapping():
Schedule::command('billing:sync-stripe-subscriptions')
->everyFiveMinutes()
->onOneServer() // only one server picks it up
->withoutOverlapping(); // and only one instance runs at a time
Both locks are different: onOneServer() prevents multiple servers from running the same scheduled task at the same time. withoutOverlapping() prevents multiple instances from running on the same server if a previous run is still in progress. Using both is correct when you want exactly-once execution across a multi-server deployment.
Running Commands in the Background
By default, schedule:run is synchronous — it runs one scheduled command, waits for it to finish, then runs the next. If two commands are scheduled for the same minute and one takes 45 seconds, the second starts 45 seconds late.
runInBackground() runs the command in a new process and continues:
// These run in parallel rather than sequentially
Schedule::command('cache:prune-stale')->hourly()->runInBackground();
Schedule::command('notifications:send-pending')->everyFiveMinutes()->runInBackground();
Schedule::command('analytics:aggregate-events')->hourly()->runInBackground();
The tradeoff: runInBackground() commands don’t write output back through the scheduler’s output handling. If you’ve configured ->sendOutputTo() or ->appendOutputTo(), background commands won’t respect it — they need their own logging.
Don’t use runInBackground() with withoutOverlapping() unless you understand the interaction: the overlap lock is acquired before the background process starts, and released when the background process finishes. If the background process crashes without releasing the lock, the expiresAt value is what prevents the next run from being permanently blocked.
Output Logging — Knowing What Your Commands Actually Did
Commands that run silently in production are commands you can’t debug when they break. The scheduler provides several output handling options:
// Write output to a file — overwrites on each run
Schedule::command('reports:generate-weekly')
->weekly()
->sendOutputTo(storage_path('logs/weekly-report.log'));
// Append output to a file — keeps history
Schedule::command('billing:sync-subscriptions')
->everyFiveMinutes()
->appendOutputTo(storage_path('logs/billing-sync.log'));
// Email output when the command produces any output
Schedule::command('database:integrity-check')
->daily()
->emailOutputTo(config('app.ops_email'));
// Email output only on failure (exit code != 0)
Schedule::command('database:backup')
->daily()
->emailOutputOnFailure(config('app.ops_email'));
The logging approach that works best in practice: append to a daily rolling log file, prune files older than 7 days:
Schedule::command('reports:generate-weekly')
->weekly()
->appendOutputTo(storage_path(
'logs/scheduler/' . now()->format('Y-m-d') . '-weekly-report.log'
));
// Prune old log files as a scheduled task
Schedule::call(function () {
$logDir = storage_path('logs/scheduler/');
collect(glob($logDir . '*.log'))
->filter(fn ($file) => filemtime($file) < now()->subDays(7)->timestamp)
->each(fn ($file) => unlink($file));
})->daily();
Before and After Hooks
The scheduler supports hooks that run before and after a command:
Schedule::command('database:backup')
->dailyAt('01:00')
->before(function () {
// Disable writes to the database before backup starts
// Or notify the ops channel that backup is starting
Log::info('Database backup started', ['at' => now()->toIso8601String()]);
})
->after(function () {
Log::info('Database backup completed', ['at' => now()->toIso8601String()]);
})
->onSuccess(function () {
// Only runs if exit code is 0
Notification::route('slack', config('services.slack.ops_webhook'))
->notify(new BackupSucceededNotification());
})
->onFailure(function () {
// Only runs if exit code is non-zero
Notification::route('slack', config('services.slack.ops_webhook'))
->notify(new BackupFailedNotification());
});
onSuccess() and onFailure() receive the Illuminate\Console\Scheduling\Event instance, which contains the command output:
->onFailure(function (Stringable $output) {
// $output contains what the command printed to stdout/stderr
Log::error('Backup failed', [
'output' => (string) $output,
'at' => now()->toIso8601String(),
]);
})
Scheduled Maintenance Windows — Running Commands Only in Specific Windows
Some commands should only run during off-hours. The between() and unlessBetween() methods gate commands by time window:
// Run only during off-peak hours (midnight to 6am)
Schedule::command('database:reindex')
->everyThirtyMinutes()
->between('00:00', '06:00');
// Skip during business hours (9am to 5pm)
Schedule::command('cache:warmup')
->everyFifteenMinutes()
->unlessBetween('09:00', '17:00');
// Only on weekends
Schedule::command('analytics:full-recalculation')
->dailyAt('03:00')
->when(fn () => now()->isWeekend());
The when() method is the most flexible gate — it accepts a closure that receives no arguments but can access any application state:
// Skip during deployment (check a deployment flag in cache/DB)
Schedule::command('notifications:process-queue')
->everyMinute()
->when(fn () => !Cache::has('deployment:in-progress'));
// Only run when there's actual work to do
Schedule::command('exports:process-pending')
->everyFiveMinutes()
->when(fn () => Export::where('status', 'pending')->exists());
// Skip during the first 10 minutes of each hour (when traffic peaks)
Schedule::command('analytics:aggregate')
->hourly()
->when(fn () => now()->minute >= 10);
skip() is the inverse of when():
Schedule::command('billing:charge-subscriptions')
->dailyAt('09:00')
->skip(fn () => app()->environment('testing', 'local'));
Timezone-Aware Scheduling
The scheduler runs in the application’s configured timezone by default (config('app.timezone')). For applications with global users or specific business requirements, per-command timezone overrides are essential:
// Send weekly digest at 9am Monday in Indian Standard Time
Schedule::command('digests:send-weekly')
->weeklyOn(1, '09:00')
->timezone('Asia/Kolkata');
// Run billing at start of business in UTC (avoids confusion with DST)
Schedule::command('billing:daily-charges')
->dailyAt('08:00')
->timezone('UTC');
// US East Coast business hours for a US-focused operation
Schedule::command('reports:morning-summary')
->weekdays()
->at('07:00')
->timezone('America/New_York');
The timezone gotcha with Daylight Saving Time: commands scheduled at times that don’t exist due to DST transitions (e.g., 2:30am when clocks spring forward) may be skipped or run twice depending on the PHP timezone handling. For critical commands, schedule them at times that are unambiguous — not at 2:00am–3:00am in timezones with DST transitions.
For a SaaS with tenants in multiple timezones who each need notifications at “their” 9am:
// Dynamic scheduling based on tenant timezones
// This runs for each timezone group separately
$timezoneGroups = Tenant::active()
->select('timezone')
->distinct()
->pluck('timezone');
foreach ($timezoneGroups as $timezone) {
Schedule::call(function () use ($timezone) {
Artisan::call('notifications:send-morning', ['--timezone' => $timezone]);
})
->dailyAt('09:00')
->timezone($timezone)
->name("morning-notifications-{$timezone}")
->onOneServer();
}
Heartbeat Monitoring With Oh Dear
The hardest part of production scheduling isn’t setting up the schedule — it’s knowing when the schedule has stopped running. A cron entry that fails silently, a server that stops executing the scheduler, a deployment that breaks schedule:run — all of these result in jobs that simply don’t run, with no alert and no log entry.
Oh Dear’s (ohdear.app) scheduled task monitoring works by expecting a “heartbeat” ping on a regular interval. If the ping doesn’t arrive within the expected window, an alert fires.
// Ping Oh Dear after a successful run
Schedule::command('database:backup')
->dailyAt('01:00')
->onSuccess(function () {
Http::get(config('services.ohdear.heartbeat_backup_url'));
});
For the scheduler itself — not individual commands, but the entire schedule:run execution:
// Confirm the scheduler is running every minute
// Oh Dear expects a ping every 2 minutes — if missed, alert fires
Schedule::call(function () {
Http::get(config('services.ohdear.heartbeat_scheduler_url'));
})->everyMinute();
The second pattern is the more important one. The first monitors that specific commands complete successfully. The second monitors that the scheduler itself is running at all. If the cron entry breaks, the scheduler heartbeat stops pinging, and the alert fires within 2 minutes.
Laravel Pulse (first-party) also provides scheduler monitoring as of Pulse 2.0:
// config/pulse.php
'recorders' => [
\Laravel\Pulse\Recorders\ScheduledTasksRecorder::class => [
'enabled' => env('PULSE_SCHEDULE_ENABLED', true),
],
],
Pulse records every scheduled task execution, its duration, and its exit status in a dashboard. It doesn’t alert on missed runs (Oh Dear does), but it provides the history needed to debug when something went wrong.
Scheduling Closures vs Commands
The scheduler accepts both Artisan commands and closures. Each has the right use case:
// Command — right for complex logic, testable independently
Schedule::command('reports:generate-weekly')->weeklyOn(1, '06:00');
// Closure — right for simple operations, useful for ad-hoc scheduling
Schedule::call(function () {
// Simple one-off task that doesn't warrant a full command
DB::table('password_reset_tokens')
->where('created_at', '<', now()->subHour())
->delete();
})->hourly()->name('prune-password-resets');
// Job class — right for queue-integrated work
Schedule::job(new SyncStripeSubscriptions())->everyFiveMinutes();
->name() on a closure is important for two reasons: schedule:list shows the name instead of “Closure”, and withoutOverlapping() uses the name as the lock key. Anonymous closures get a lock key based on file and line number, which can change across deployments. Named closures get a stable lock key.
For queue-integrated work, Schedule::job() dispatches a job class to the queue rather than running directly. This is right when the work should be distributed across queue workers rather than executed synchronously in the scheduler process.
The Complete Schedule for a Typical SaaS
What a production SaaS schedule looks like when all the features are used correctly:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
// Billing — single server, no overlap, business hours only
Schedule::command('billing:sync-stripe-subscriptions')
->everyFiveMinutes()
->withoutOverlapping(expiresAt: 10)
->onOneServer()
->appendOutputTo(storage_path('logs/scheduler/' . now()->format('Y-m-d') . '-billing.log'))
->onFailure(fn (Stringable $output) => Log::error('Billing sync failed', ['output' => (string) $output]));
// Database backup — once daily at 1am, alert on failure
Schedule::command('database:backup')
->dailyAt('01:00')
->withoutOverlapping()
->onOneServer()
->onSuccess(fn () => Http::get(config('services.ohdear.heartbeat_backup')))
->onFailure(fn (Stringable $output) =>
Notification::route('mail', config('alerts.ops_email'))
->notify(new BackupFailedNotification($output))
);
// Weekly report — Monday 6am IST, single server
Schedule::command('reports:generate-weekly')
->weeklyOn(1, '06:00')
->timezone('Asia/Kolkata')
->withoutOverlapping(expiresAt: 120)
->onOneServer()
->appendOutputTo(storage_path('logs/scheduler/' . now()->format('Y-m-d') . '-reports.log'));
// Cleanup — overnight only, background, no overlap
Schedule::command('database:cleanup-expired-sessions')
->hourly()
->between('00:00', '06:00')
->runInBackground()
->withoutOverlapping(expiresAt: 20);
// Cache pruning — skip during peak hours
Schedule::command('cache:prune-stale')
->everyThirtyMinutes()
->unlessBetween('09:00', '18:00')
->runInBackground();
// Onboarding nudges — weekdays only, only if pending work exists
Schedule::command('onboarding:send-nudges')
->weekdays()
->at('10:00')
->when(fn () => OnboardingStep::incomplete()->exists())
->withoutOverlapping()
->onOneServer();
// Scheduler heartbeat — every minute, monitors the scheduler itself
Schedule::call(fn () => Http::get(config('services.ohdear.heartbeat_scheduler')))
->everyMinute();
The schedule:work Command for Development
Running schedule:run in production requires a cron entry. In development, schedule:work runs the scheduler in the foreground, executing schedule:run every minute:
php artisan schedule:work
# Runs indefinitely, simulating the production cron
# Ctrl+C to stop
This replaces the need for a local cron entry and makes it easy to test that scheduled commands run at the right frequency during development. The commands run at their actual scheduled times — a ->daily() command won’t run every minute in schedule:work, only when the time condition is met.
For testing that a specific command runs correctly regardless of time:
php artisan schedule:test
# Interactive list of all scheduled tasks
# Select any task to run it immediately
What the Documentation Doesn’t Tell You
The single most important non-obvious fact about Laravel’s scheduler: every invocation of schedule:run is independent. There’s no persistent scheduler process. The cron fires, PHP starts, evaluates the schedule, runs due commands, then exits. If the server restarts, the next cron invocation starts clean. If the cron entry is missing for an hour, the commands that were due in that hour simply don’t run — there’s no backfill, no catch-up, no queued history of missed runs.
This is a design choice. It means the scheduler is stateless and survivable. It also means missed runs are missed permanently. For commands that must not be missed — billing charges, compliance-required reports, SLA-triggered alerts — the scheduler alone isn’t sufficient. Those need additional monitoring (Oh Dear), idempotent retry logic in the command itself, or a separate job queue for the critical path.
The scheduler is a cron replacement for recurring background work. It’s not a guarantee that work will happen. Know the difference.
