You deploy, run your smoke tests, ship the release notes, and go to lunch. Two hours later, a customer emails: their daily report never arrived. You check the scheduler, and it hasn’t run since the deploy finished.
This happens more often than anyone admits. Deploys interact with the cron system in subtle ways, and most deployment pipelines don’t verify that scheduled tasks actually resume after a release.
Why deploys break the scheduler
There are several distinct ways a deploy can silently break your Laravel cron setup. Understanding each one helps you know what to check.
The crontab entry gets wiped during provisioning
If you use any infrastructure-as-code tool that rebuilds your server image or reprovisions the cron configuration, the crontab entry for schedule:run can disappear silently. Terraform, Ansible, and even Forge’s server provisioning scripts can reset the crontab if the task definition isn’t explicitly included.
After every deploy, verify the crontab entry exists:
sudo crontab -u www-data -l | grep schedule:run
If that returns nothing, the entry is gone. Re-add it immediately:
# Add the entry if it's missing
(crontab -u www-data -l 2>/dev/null; echo "* * * * * cd /var/www/html && php artisan schedule:run >> /var/log/laravel-schedule.log 2>&1") | crontab -u www-data -
A more robust approach: make the crontab entry part of your deploy script so it’s re-applied every time, not just during provisioning.
# In your deploy script
php artisan schedule:install
Symlink-based deploys change the working directory
Zero-downtime deploy tools like Envoyer, Deployer, and Forge’s zero-downtime deploy feature work by creating a new release directory and then swapping a current symlink to point at it. If your crontab entry uses the symlink path (/var/www/html/current), the cd resolves correctly after the swap. But if it uses a hardcoded release path, it points at stale code after the next deploy.
Check what your crontab actually references:
sudo crontab -u www-data -l
You want to see the symlink path, not a release-specific path:
# Correct - works across deploys
* * * * * cd /var/www/html/current && php artisan schedule:run >> /var/log/laravel-schedule.log 2>&1
# Wrong - breaks after the next deploy
* * * * * cd /var/www/html/releases/42 && php artisan schedule:run >> /var/log/laravel-schedule.log 2>&1
Config cache points to old paths
Running php artisan config:cache during a deploy serializes all config values into a cached file. If the deploy creates a new release directory, the cached config might contain paths from the old release. Scheduled tasks that depend on config('filesystems.disks.local.root') or storage_path() can resolve to directories that no longer exist under the new release.
The fix is ordering your deploy steps correctly:
# 1. Pull new code
git pull origin main
# 2. Install dependencies
composer install --no-dev --optimize-autoloader
# 3. Run migrations
php artisan migrate --force
# 4. Clear old cache FIRST - before the symlink swap
php artisan config:clear
# 5. Then rebuild cache from new code
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 6. Swap the symlink to new release
# (this is handled by your deployer tool)
If you cache config before the symlink swap, the cached paths point at the wrong directory and scheduled tasks fail silently.
PHP-FPM restarts kill running schedule:run processes
When a deploy restarts PHP-FPM (which most do), any currently running php artisan schedule:run process gets terminated mid-execution. If a scheduled task was running, it dies without completing or recording a result.
That’s usually fine. The problem comes when the FPM restart takes longer than expected, or when the restart fails entirely. If PHP-FPM doesn’t come back, the next cron tick’s schedule:run fails with a connection error, but the cron daemon still reports exit code 0 because the cd and php commands themselves succeeded.
Check FPM status after a deploy:
sudo systemctl status php8.3-fpm
And verify schedule:run can actually execute:
sudo -u www-data bash -c "cd /var/www/html/current && php artisan schedule:run --no-ansi"
Composer autoload changes break class resolution
If a deploy changes the namespace or class name of a scheduled command but the autoloader hasn’t been regenerated, schedule:run will silently skip that task. The class can’t be resolved, the scheduler logs nothing, and the task just doesn’t appear in the schedule.
Always run composer dump-autoload as part of your deploy:
composer install --no-dev --optimize-autoloader
# The --optimize flag generates a classmap, which implicitly runs dump-autoload
After a deploy, verify your schedule looks correct:
php artisan schedule:list
If a task you expect is missing from the output, the class isn’t being resolved. Check the namespace in app/Console/Kernel.php and the autoload configuration in composer.json.
Building a post-deploy verification step
Rather than checking each of these individually, build a single verification command that runs after every deploy:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class VerifyDeployHealth extends Command
{
protected $signature = 'deploy:verify';
protected $description = 'Verify cron and queue health after a deploy';
public function handle(): int
{
$failures = [];
// Check crontab entry exists
$crontab = shell_exec('crontab -l 2>/dev/null');
if (!str_contains($crontab ?? '', 'schedule:run')) {
$failures[] = 'Missing schedule:run crontab entry';
$this->error('Missing schedule:run crontab entry');
}
// Check schedule has expected tasks
$scheduleList = shell_exec('php artisan schedule:list 2>&1');
if (str_contains($scheduleList ?? '', 'No scheduled tasks')) {
$this->warn('No scheduled tasks registered - verify Kernel.php');
}
// Check config cache is fresh
$cachedConfigPath = base_path('bootstrap/cache/config.php');
if (file_exists($cachedConfigPath)) {
$age = time() - filemtime($cachedConfigPath);
if ($age > 300) {
$failures[] = "Config cache is {$age}s old, may be stale";
$this->warn("Config cache is {$age}s old, may be stale");
}
}
// Check storage directory exists
if (!is_dir(storage_path())) {
$failures[] = 'Storage directory not found';
$this->error('Storage directory not found');
}
if (!empty($failures)) {
$this->error('Deploy health checks failed. Fix the above issues before continuing.');
return Command::FAILURE;
}
$this->info('Deploy health checks passed.');
return Command::SUCCESS;
}
}
Add it to the end of your deploy script:
php artisan deploy:verify || { echo "DEPLOY HEALTH CHECK FAILED"; exit 1; }
The command exits with a non-zero code if any check fails, causing the deploy to abort and notify you before the new release goes live.
Catching deploy-related cron failures automatically
The post-deploy verification step catches immediate problems from the deploy script. But some failures surface hours later: a task that runs hourly won’t be missed until the next hour passes. A task that runs daily won’t be missed until the next day.
The real danger is the silence. If the deploy verification passes but the scheduler is broken, nothing alerts you until a customer notices.
Crontinel tracks the expected frequency of every registered scheduled task and alerts you when a task misses its window. If your hourly report hasn’t run within 75 minutes of its expected time, you get a notification regardless of whether the deploy script reported success.
composer require crontinel/laravel
php artisan crontinel:install
The missed-run detection works independently of the scheduler itself. Even if schedule:run is completely broken after a deploy, Crontinel’s external heartbeat catches the gap and fires an alert before your users do.
See crontinel.com/features for how missed-run windows and alerting channels are configured.
The short version: deploys break cron in five predictable ways. Check your crontab after every deploy, verify schedule:list shows the right tasks, and layer in automated missed-run detection so you’re notified before customers report the problem.