How to Verify Laravel Scheduled Cron Jobs Are Running Correctly on Production
To verify Laravel scheduled cron jobs are running correctly on production, ensure your server has a single system cron entry invoking php artisan schedule:run every minute, configure a persistent cache driver for mutex locks, and inspect output logs or run the scheduler manually with --verbose to confirm due events execute.
Laravel's task scheduler simplifies server automation by allowing you to define cron jobs in PHP rather than managing multiple system crontab entries. According to the laravel/framework source code, the scheduler relies on a single system cron that triggers the schedule:run Artisan command, which then evaluates and executes due tasks defined in your application's Console Kernel. Understanding this execution flow is essential for debugging production scheduling issues and ensuring critical background tasks complete reliably.
How the Laravel Scheduler Works
The scheduler architecture consists of four core components that coordinate to run your tasks every minute.
The System Cron Entry
The only server-level configuration required is a single crontab entry that invokes Laravel's Artisan dispatcher every minute. The system cron daemon executes:
* * * * * php /path/to/your/application/artisan schedule:run >> /dev/null 2>&1
This entry ensures php artisan schedule:run fires continuously, allowing Laravel to decide which tasks are due based on their defined frequencies.
ScheduleRunCommand Execution
When the cron triggers, src/Illuminate/Console/Scheduling/ScheduleRunCommand.php handles the request via its handle() method. This command resolves the Schedule instance and filters for due events:
// src/Illuminate/Console/Scheduling/ScheduleRunCommand.php
$events = $this->schedule->dueEvents($this->laravel);
foreach ($events as $event) {
// Execution logic...
}
The command iterates through due events and either runs them immediately or delegates to runSingleServerEvent() if onOneServer is enabled.
Event Filtering and Due Checks
The Schedule class in src/Illuminate/Console/Scheduling/Schedule.php collects all defined events and filters them using dueEvents():
// src/Illuminate/Console/Scheduling/Schedule.php
public function dueEvents($app)
{
return (new Collection($this->events))->filter->isDue($app);
}
Each Event object (defined in src/Illuminate/Console/Scheduling/Event.php) evaluates its Cron expression, environment constraints, and maintenance mode status through the isDue() method before being passed to the execution loop.
Mutex and Overlap Protection
To prevent concurrent execution, the Event class implements withoutOverlapping protection via the shouldSkipDueToOverlapping() method:
// src/Illuminate/Console/Scheduling/Event.php
public function shouldSkipDueToOverlapping()
{
return $this->withoutOverlapping && ! $this->mutex->create($this);
}
For multi-server deployments, the Schedule class provides onOneServer logic through serverShouldRun() in src/Illuminate/Console/Scheduling/Schedule.php, which creates a scheduling mutex ensuring only one server executes a given event at the scheduled time.
Configuring Production Cron Jobs
Proper configuration requires synchronizing your system cron, application kernel, and cache infrastructure.
System-Level Crontab Setup
Add the following entry to your server's crontab (edit via crontab -e):
* * * * * php /var/www/myapp/artisan schedule:run >> /dev/null 2>&1
Ensure the PHP binary path matches your application environment. Laravel resolves the binary automatically via Application::phpBinary() as implemented in src/Illuminate/Console/Application.php.
Defining Tasks in the Console Kernel
Define your scheduled tasks in app/Console/Kernel.php by overriding the abstract schedule() method from src/Illuminate/Foundation/Console/Kernel.php:
use Illuminate\Console\Scheduling\Schedule;
protected function schedule(Schedule $schedule)
{
$schedule->command('emails:send')
->dailyAt('02:00')
->withoutOverlapping()
->onOneServer()
->storeOutput();
}
The withoutOverlapping method prevents new instances from starting if a previous execution is still running, while onOneServer ensures the job runs on only one server in a horizontally scaled environment.
Cache Driver Configuration
Both overlap protection and single-server execution require a persistent cache store. Set your driver in .env:
CACHE_DRIVER=redis
# or specifically for scheduling:
SCHEDULE_CACHE_DRIVER=redis
Alternatively, force a specific store within your kernel using useCache():
$schedule->useCache('redis');
This ensures mutex locks survive across multiple schedule:run invocations and server restarts.
Verification and Monitoring Strategies
Proactive monitoring prevents silent failures in production environments.
Manual Testing with Verbose Output
Test your schedule definition manually by running:
php artisan schedule:run --verbose
This outputs "Running [command]" for each due event, allowing you to verify that Cron expressions evaluate correctly and constraints (environments, maintenance mode) permit execution.
Inspecting Logs and Output
By default, scheduled tasks discard output to /dev/null. Enable logging using storeOutput() or sendOutputTo():
$schedule->command('backup:run')
->daily()
->storeOutput();
Logs are stored at storage_path('logs/schedule-'.sha1($event->mutexName()).'.log') by default. Review these files to verify successful completion or capture error messages.
Checking Mutex Cache Keys
Verify that locks are being created and released correctly:
- File-based cache: Inspect
storage/framework/cache/data/illuminate:schedule:* - Redis: Check for keys matching
framework:schedule-{sha1}usingredis-cli KEYS 'framework:schedule*'
If these keys persist after a task completes, the mutex may not be clearing properly, indicating a potential crash or timeout during execution.
Building Health Check Endpoints
Expose a monitoring endpoint to report upcoming scheduled runs:
Route::get('/schedule/next', function (Schedule $schedule) {
return $schedule->events()
->map(fn ($e) => [
'command' => $e->getSummaryForDisplay(),
'next' => $e->nextRunDate()->toDateTimeString(),
]);
});
This allows external monitoring systems to verify that tasks remain scheduled and calculate expected execution times.
Handling Failures and Debugging
Implement failure detection to respond to missed or failed executions.
Failure Callbacks and Notifications
Attach callbacks to handle failures gracefully:
$schedule->command('backup:run')
->daily()
->onFailure(fn () => Log::error('Backup job failed'))
->emailOutputOnFailure('admin@example.com');
The onFailure() callback executes immediately when a command returns a non-zero exit code, while emailOutputOnFailure() sends the captured output to specified recipients.
Clearing Stale Cache
If you modify schedule definitions while the configuration is cached, clear the scheduling mutex cache:
php artisan schedule:clear-cache
This removes stale entries from CacheEventMutex and CacheSchedulingMutex implementations, preventing "ghost" locks from blocking new executions.
Summary
- System Cron: A single crontab entry running
php artisan schedule:runevery minute drives the entire scheduling system. - Mutex Configuration: Enable
withoutOverlappingandonOneServeronly after configuring a persistent cache driver (Redis recommended) viaCACHE_DRIVERorSCHEDULE_CACHE_DRIVER. - Verification: Use
schedule:run --verbosefor manual testing, inspectstorage/logs/schedule-*.logfor output, and monitor Redis keys matchingframework:schedule-*for lock status. - Source Files: The core logic resides in
src/Illuminate/Console/Scheduling/ScheduleRunCommand.php,src/Illuminate/Console/Scheduling/Schedule.php, andsrc/Illuminate/Console/Scheduling/Event.php. - Cleanup: Use
php artisan schedule:clear-cachewhen deploying schedule changes to prevent mutex conflicts.
Frequently Asked Questions
How do I check if my Laravel cron job is actually running?
Run php artisan schedule:run --verbose manually to see which events are due and whether they execute. Additionally, check your configured log files (via storeOutput()) or implement an onSuccess callback that writes to your application logs. If using onOneServer, verify the cache keys exist in your Redis or file cache to confirm a server has claimed the lock.
What cache driver should I use for Laravel scheduled tasks in production?
Use Redis or Memcached for production environments. File-based caching works for single-server deployments but can cause race conditions on multi-server setups. Set CACHE_DRIVER=redis in your .env file, or specify a dedicated driver via SCHEDULE_CACHE_DRIVER if your application cache uses a different backend.
Why is my Laravel scheduler running the same job multiple times?
This occurs when onOneServer() is not used in multi-server deployments, or when the cache driver is misconfigured (e.g., using the array driver which is not persistent). Ensure your cache driver supports atomic locks and that all servers share the same cache instance. Check src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php for the locking implementation details.
Where are Laravel scheduled task logs stored?
By default, logs are not stored unless you call storeOutput() or sendOutputTo($path) on the event. When using storeOutput(), logs are written to storage/logs/schedule-{hash}.log where the hash is derived from the event's mutex name. You can specify a custom path using sendOutputTo('/custom/path.log') on your scheduled task definition.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →