How to Monitor Laravel Scheduled Tasks in Production
Your nightly database backup hasn't run in three weeks. Your subscription renewal emails stopped firing last Tuesday. Your cache cleanup task silently died after a deployment, and nobody noticed until the Redis instance ran out of memory.
These aren't hypothetical scenarios—they're the reality of running Laravel scheduled tasks in production without proper monitoring. Unlike HTTP requests that immediately show errors, scheduled tasks fail silently. There's no user refreshing a page, no error page to screenshot, no support ticket filed. The task simply... doesn't run.
This guide covers everything you need to monitor Laravel scheduled tasks effectively: native Laravel features, common production failures and how to debug them, self-hosted packages, and external monitoring services. Whether you're running a single server or a distributed deployment across multiple regions, you'll find a monitoring approach that fits.
How Laravel Task Scheduling Works (Quick Refresher)
Before diving into monitoring, let's ensure we're on the same page about how Laravel's scheduler operates.
Laravel's task scheduler lets you define all your scheduled tasks within your application code rather than managing dozens of server cron entries. You only need a single cron entry on your server:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1This cron runs every minute, executing schedule:run which checks your defined tasks and runs any that are due.
Defining Tasks in Laravel 11+
Laravel 11 introduced a streamlined application structure that eliminates the app/Console/Kernel.php file. You now define scheduled tasks in one of two places:
Option 1: In routes/console.php (recommended for most apps)
use Illuminate\Support\Facades\Schedule;
Schedule::command('emails:send-daily-digest')
->dailyAt('08:00')
->timezone('America/New_York');
Schedule::job(new ProcessSubscriptionRenewals)
->hourly()
->withoutOverlapping();
Schedule::call(function () {
Cache::flush();
})->weekly()->sundays()->at('03:00');Option 2: In bootstrap/app.php (useful for complex configurations)
->withSchedule(function (Schedule $schedule) {
$schedule->command('backup:run')->daily();
$schedule->command('telescope:prune')->daily();
})If you're on Laravel 10 or maintaining a legacy application, tasks are still defined in the schedule method of app/Console/Kernel.php.
Why Scheduled Tasks Fail Silently
The core problem with scheduled tasks is their fire-and-forget nature. When a task fails:
- No HTTP response code indicates failure
- No user witnesses the error
- Logs may not capture the issue (especially with output suppression)
- The scheduler moves on to the next task without alerting anyone
A task might fail for dozens of reasons: database connection timeouts, memory exhaustion, third-party API outages, file permission issues, or simply because someone forgot to add the cron entry after deploying to a new server.
This is why monitoring isn't optional—it's essential for any production Laravel application with scheduled tasks.
Laravel's Built-in Monitoring Features
Before reaching for external tools, Laravel provides several native features for monitoring and responding to task execution.
Ping URLs on Task Lifecycle Events
Laravel can ping external URLs at different points in a task's lifecycle. This is the foundation for integrating with any heartbeat-based monitoring service:
Schedule::command('backup:database')
->daily()
->pingBefore('https://monitoring.example.com/task-123/start')
->pingOnSuccess('https://monitoring.example.com/task-123')
->pingOnFailure('https://monitoring.example.com/task-123/fail');The available ping methods are:
| Method | When It Fires |
|---|---|
pingBefore($url) |
Before the task starts |
thenPing($url) |
After the task completes (success or failure) |
pingOnSuccess($url) |
Only when task succeeds |
pingOnFailure($url) |
Only when task fails |
pingBeforeIf($condition, $url) |
Conditionally before start |
thenPingIf($condition, $url) |
Conditionally after completion |
For tasks where you want to track execution time, use both pingBefore and pingOnSuccess—the monitoring service can calculate duration from the timestamps.
Success and Failure Callbacks
Beyond pinging URLs, you can execute arbitrary code when tasks succeed or fail:
Schedule::command('reports:generate-monthly')
->monthlyOn(1, '02:00')
->onSuccess(function () {
Log::info('Monthly report generated successfully');
Notification::route('slack', config('services.slack.webhook'))
->notify(new TaskCompletedNotification('Monthly Report'));
})
->onFailure(function () {
Log::error('Monthly report generation failed');
Notification::route('slack', config('services.slack.webhook'))
->notify(new TaskFailedNotification('Monthly Report'));
});Important: For onFailure to trigger, your command must return a non-zero exit code. In artisan commands, return Command::FAILURE or 1:
public function handle()
{
try {
// Task logic here
return Command::SUCCESS;
} catch (\Exception $e) {
Log::error('Task failed: ' . $e->getMessage());
return Command::FAILURE; // This triggers onFailure()
}
}Output Handling
Capture task output for debugging:
// Send output to a file
Schedule::command('process:payments')
->daily()
->sendOutputTo('/var/log/laravel-scheduler/payments.log');
// Append instead of overwrite
Schedule::command('process:payments')
->daily()
->appendOutputTo('/var/log/laravel-scheduler/payments.log');
// Email output (only if there's output)
Schedule::command('reports:daily')
->daily()
->emailOutputOnFailure('ops@example.com');The schedule:list Command
Laravel provides visibility into your defined tasks:
php artisan schedule:listThis outputs a table showing all scheduled tasks, their frequency, and next run time. Useful for auditing but doesn't show execution history.
Testing Tasks Locally
Before deploying, test your scheduled tasks interactively:
# Run a specific task immediately
php artisan schedule:test
# This shows a menu of all scheduled tasks - select one to runFor local development, use schedule:work instead of setting up cron:
php artisan schedule:workThis runs the scheduler in the foreground, executing tasks as they come due. It's invaluable for testing schedule timing without waiting.
Common Production Problems (and How to Fix Them)
Understanding why tasks fail helps you set up appropriate monitoring. Here are the issues that catch teams most often.
The Missing Cron Entry
The number one reason Laravel scheduled tasks don't run in production: the cron entry doesn't exist. It's embarrassingly simple but devastatingly common.
After deploying to a new server, provisioning a new environment, or migrating hosting providers, the cron entry must be manually added. It lives outside your codebase and outside version control.
Diagnosis:
# Check if cron entry exists
crontab -l
# On Laravel Forge, check the system crontab
cat /etc/crontabSolution: Add the cron entry, and consider adding cron setup to your deployment documentation or infrastructure-as-code configuration.
PHP Path Mismatches
On shared hosting or servers with multiple PHP versions, cron might use a different PHP binary than expected:
# Your app expects PHP 8.2
/usr/local/bin/php82 artisan schedule:run
# But cron uses system default PHP 7.4
/usr/bin/php artisan schedule:runDiagnosis:
# Check which PHP cron uses
which php
# Compare to your expected version
php -vSolution: Use absolute paths in your cron entry:
* * * * * cd /var/www/app && /usr/local/bin/php82 artisan schedule:run >> /dev/null 2>&1The withoutOverlapping Lock Trap
withoutOverlapping() prevents a task from running if a previous instance is still executing. It uses cache-based mutex locks—and here's the trap:
If a task crashes mid-execution or the server reboots unexpectedly, the lock persists for 24 hours by default. Your task won't run again until the lock expires.
// Default: lock expires after 24 hours
Schedule::command('import:large-dataset')
->hourly()
->withoutOverlapping();
// Better: set a reasonable expiration
Schedule::command('import:large-dataset')
->hourly()
->withoutOverlapping(30); // Lock expires after 30 minutesDiagnosis: Tasks skip silently. Check your logs for "Skipping task" messages.
Solution: Clear the scheduler cache:
php artisan schedule:clear-cacheCritical for closures and jobs: When using withoutOverlapping() with closures or queued jobs, you must call name() first:
// WRONG - withoutOverlapping won't work correctly
Schedule::call(function () {
// ...
})->hourly()->withoutOverlapping();
// CORRECT - name() must come before withoutOverlapping()
Schedule::call(function () {
// ...
})->name('my-closure-task')->hourly()->withoutOverlapping();Timezone and Daylight Saving Time Issues
Tasks scheduled around DST transitions can fire twice or skip entirely. A task scheduled for 2:30 AM might:
- Run twice when clocks fall back (2:30 AM happens twice)
- Never run when clocks spring forward (2:30 AM doesn't exist)
Solution: Use explicit timezones and avoid scheduling during transition hours (typically 2-3 AM):
Schedule::command('reports:daily')
->dailyAt('04:00')
->timezone('UTC'); // UTC doesn't observe DSTOr set a default timezone for all tasks in your configuration:
// config/app.php
'schedule_timezone' => 'UTC',Memory Exhaustion
Long-running tasks processing large datasets can exhaust PHP's memory limit:
Schedule::command('export:all-users')
->daily()
->runInBackground(); // Prevents blocking other tasksSolutions:
- Use chunking when processing large datasets
- Increase memory limit for specific commands
- Use
runInBackground()so memory issues don't block other tasks - Monitor memory usage and set up alerts
Multi-Server Scheduling Conflicts
Running the scheduler on multiple servers? Without coordination, tasks run on every server:
Schedule::command('reports:daily')
->daily()
->onOneServer(); // Only runs on one serverRequirement: onOneServer() requires a centralized cache driver (Redis or Memcached). File-based cache won't work across servers.
Monitoring Solutions: Packages and Services
Now that you understand what can go wrong, let's explore monitoring solutions that catch these failures before your users do.
Self-Hosted: spatie/laravel-schedule-monitor
Spatie's package is the most popular self-hosted solution for Laravel schedule monitoring, with over 1,300 GitHub stars.
Installation:
composer require spatie/laravel-schedule-monitor
php artisan vendor:publish --provider="Spatie\ScheduleMonitor\ScheduleMonitorServiceProvider"
php artisan migrateWhat it tracks:
- Task start and completion times
- Execution duration
- Memory usage
- Failures and skip reasons
Viewing task status:
php artisan schedule-monitor:listThis shows a table of all monitored tasks with their last run status, duration, and next scheduled run.
Syncing with Oh Dear (optional):
The package integrates with Oh Dear for external alerting:
php artisan schedule-monitor:syncThis is an excellent choice if you want database-backed history without depending on external services, though you'll need to build your own alerting on top of it.
External Monitoring Services
Heartbeat-based monitoring services work on a simple principle: your task pings a URL on completion. If the ping doesn't arrive within the expected schedule plus a grace period, you get alerted.
This "dead man's switch" approach catches both task failures and infrastructure problems—if your entire server goes down, the pings stop arriving.
Healthchecks.io
The most generous free tier in the space with 20 monitors included. Open-source and self-hostable if you prefer.
Setup:
- Create a check at healthchecks.io with your task's schedule
- Copy the ping URL
- Add to your task:
Schedule::command('backup:database')
->daily()
->pingOnSuccess('https://hc-ping.com/your-uuid-here');Pricing: Free for 20 monitors, $20/month for 100 monitors.
cronradar">CronRadar
CronRadar differentiates through Laravel-specific features, including automatic discovery and syncing of your scheduled tasks—no manual monitor creation needed.
Setup:
composer require cronradar/laravel// config/cronradar.php
return [
'api_key' => env('CRONRADAR_API_KEY'),
'auto_discover' => true,
];With auto-discovery enabled, CronRadar automatically detects your scheduled tasks and creates monitors for them. When you add new tasks to your schedule, they're automatically monitored without additional configuration.
Manual integration (if you prefer explicit control):
Schedule::command('backup:database')
->daily()
->pingBefore('https://cronradar.io/ping/your-monitor-id/start')
->pingOnSuccess('https://cronradar.io/ping/your-monitor-id')
->pingOnFailure('https://cronradar.io/ping/your-monitor-id/fail');Pricing: $1 per monitor per month with unlimited team members.
Oh Dear
Built by the team at Spatie, Oh Dear offers the deepest Laravel integration. The spatie/laravel-schedule-monitor package syncs directly with Oh Dear, automatically creating monitors for all your scheduled tasks.
Setup:
composer require spatie/laravel-schedule-monitor// config/schedule-monitor.php
'oh_dear' => [
'api_token' => env('OH_DEAR_API_TOKEN'),
'site_id' => env('OH_DEAR_SITE_ID'),
],php artisan schedule-monitor:syncEvery task in your schedule now has a corresponding monitor in Oh Dear.
Pricing: Starts around $10/month for 5 sites (each site includes multiple cron monitors).
Cronitor
A mature platform with automatic log capture, meaning you can see your task's output directly in Cronitor's dashboard without additional configuration.
Setup:
composer require cronitor/cronitor-laravel// Cronitor auto-wraps your scheduled tasks
Schedule::command('backup:database')
->daily();Pricing: $21/month for 50 monitors (Solo plan).
Better Stack (formerly Better Uptime)
Combines heartbeat monitoring with incident management, on-call scheduling, and status pages—useful if you want an all-in-one operations platform.
Setup:
Schedule::command('backup:database')
->daily()
->pingOnSuccess('https://betteruptime.com/api/v1/heartbeat/your-token');Pricing: Free for 10 monitors (3-minute intervals), $25/month for 50 monitors with 30-second intervals.
Comparison Table
| Service | Free Tier | Paid Starting | Laravel Integration | Best For |
|---|---|---|---|---|
| Healthchecks.io | 20 monitors | $20/mo (100) | Native ping methods | Budget-conscious teams |
| CronRadar | — | $1/monitor/mo | Auto-discovery package | Automated Laravel setup |
| Oh Dear | — | ~$10/mo (5 sites) | Spatie package sync | Laravel-native experience |
| Cronitor | Limited | $21/mo (50) | Official package | Log capture needs |
| Better Stack | 10 monitors | $25/mo (50) | Native ping methods | All-in-one ops platform |
| spatie/schedule-monitor | Unlimited | — | Native | Self-hosted preference |
Environment-Specific Configurations
Different hosting environments require different approaches to running and monitoring scheduled tasks.
Laravel Forge
Forge automatically adds the scheduler cron entry when you enable it in the server settings. Find it under Server → Scheduler.
Key points:
- Forge uses the system crontab (
/etc/crontab), not user crontabs - Running
crontab -lwon't show Forge-managed cron entries - Manual task runs in Forge timeout after 60 seconds
- Enable Heartbeat monitoring in Forge for basic "scheduler is running" alerts
Laravel Vapor
Vapor doesn't use cron at all. Instead, it leverages AWS EventBridge to trigger your scheduled tasks.
Vapor-specific considerations:
# vapor.yml
environments:
production:
cli-timeout: 120 # Required for sub-minute tasks
cli-concurrency: 2 # Required for sub-minute tasksCritical limitation: Log output from scheduled tasks doesn't appear in CloudWatch. If you need visibility into task output, dispatch a queued job from your scheduled task—job logs do appear in CloudWatch.
// Instead of logging directly in the scheduled command
Schedule::call(function () {
ProcessReports::dispatch(); // This job's logs will appear in CloudWatch
})->hourly();Docker Environments
Docker containers don't run cron daemons by default. You have several options:
Option 1: Use schedule:work (simplest)
CMD ["php", "artisan", "schedule:work"]This runs the scheduler in the foreground, checking for due tasks every minute.
Option 2: Supercronic (Docker-friendly cron)
# Install supercronic
ADD https://github.com/aptible/supercronic/releases/download/v0.2.1/supercronic-linux-amd64 /usr/local/bin/supercronic
RUN chmod +x /usr/local/bin/supercronic
# Add crontab file
COPY crontab /etc/crontab
CMD ["supercronic", "/etc/crontab"]Option 3: Separate scheduler container
Run your scheduler as a dedicated container in your docker-compose setup:
services:
scheduler:
image: your-laravel-app
command: php artisan schedule:work
depends_on:
- appShared Hosting
If your host doesn't allow shell access for cron, you may need to trigger the scheduler via HTTP:
// routes/web.php (protected by secret token)
Route::get('/scheduler/run', function (Request $request) {
if ($request->token !== config('app.scheduler_token')) {
abort(403);
}
Artisan::call('schedule:run');
return 'OK';
})->name('scheduler.run');Then use an external cron service (like cron-job.org or EasyCron) to hit this URL every minute.
Security warning: Always protect this endpoint with a secret token and consider IP restrictions.
Building a Monitoring Strategy
Here's a practical approach to implementing scheduled task monitoring:
Step 1: Inventory Your Tasks
Start by listing all your scheduled tasks and their criticality:
php artisan schedule:listCategorize each task:
- Critical: Business-impacting if missed (billing, backups, sync jobs)
- Important: Should run but missing one isn't catastrophic
- Nice-to-have: Cleanup tasks, cache warming
Step 2: Choose Your Monitoring Approach
For most Laravel applications, a combination works best:
- Native callbacks for logging and immediate Slack/email alerts
- External heartbeat service for critical tasks
- Self-hosted package for execution history and debugging
Step 3: Implement Graduated Alerting
Not every missed task needs to wake someone up at 3 AM:
// Critical: External monitoring + immediate alerts
Schedule::command('billing:process-renewals')
->daily()
->pingOnSuccess('https://cronradar.io/ping/billing-critical')
->pingOnFailure('https://cronradar.io/ping/billing-critical/fail')
->onFailure(function () {
// Immediate PagerDuty/Opsgenie alert
PagerDuty::trigger('Billing renewal processing failed');
});
// Important: External monitoring, alert during business hours
Schedule::command('reports:generate')
->daily()
->pingOnSuccess('https://cronradar.io/ping/reports');
// Nice-to-have: Log-based monitoring only
Schedule::command('cache:warm')
->hourly()
->onFailure(fn () => Log::warning('Cache warming failed'));Step 4: Test Your Monitoring
Before relying on your monitoring in production:
- Intentionally fail a task and verify alerts fire
- Stop the scheduler and confirm the heartbeat service alerts
- Review that log output is being captured correctly
- Test that team members receive notifications
Step 5: Document and Maintain
Create runbooks for common failures:
- How to clear scheduler cache locks
- How to manually run missed tasks
- Escalation paths for different task failures
- Recovery procedures for data-dependent tasks
Troubleshooting Checklist
When a scheduled task isn't running, work through this checklist:
1. Is the cron entry present?
crontab -l
# On Forge: cat /etc/crontab2. Is the scheduler running at all?
# Check recent runs
grep "schedule:run" /var/log/syslog | tail -203. Is your specific task defined correctly?
php artisan schedule:list
# Verify your task appears with correct timing4. Can the task run manually?
php artisan your:command
# If this fails, the issue is with the command, not scheduling5. Is there a stale lock?
php artisan schedule:clear-cache6. Are environment variables available to cron?
# Add to crontab to load environment
* * * * * cd /path && source .env && php artisan schedule:run7. Check permissions and paths
# Verify PHP path
which php
# Verify project path ownership
ls -la /path/to/projectConclusion
Monitoring Laravel scheduled tasks isn't optional—it's a fundamental part of running a production application. The good news is Laravel provides solid native tools, and the ecosystem offers excellent packages and services to fill the gaps.
Start with Laravel's built-in ping methods and callbacks for immediate visibility. Add a heartbeat monitoring service for critical tasks that need external verification. Consider a package like spatie/laravel-schedule-monitor if you want execution history without external dependencies.
The specific tools matter less than having some monitoring in place. A failed backup you learn about immediately is recoverable. A failed backup you discover three weeks later when you need it is a disaster.
Pick an approach that matches your team's needs and budget, implement it consistently across all your scheduled tasks, and test that alerts actually reach the right people. Your future self—the one not debugging a silent failure at 2 AM—will thank you.