Forge shows a green check next to your server. The daemon list looks healthy. The scheduled jobs page still has the same * * * * * php /home/forge/app.com/artisan schedule:run entry you added two years ago. And yet last night’s invoice export never ran, the queue depth climbed overnight, and nobody knew until support tickets started landing at 9am.
Forge is excellent at provisioning cron and keeping supervisor processes alive. It is not a job-level monitor. If schedule:run exits 0 while individual tasks fail, stall, or never fire, Forge has nothing to show you.
How Forge Cron Actually Works
On a Forge-managed server, scheduling is two layers:
- System cron — Forge writes a real crontab entry (usually as the
forgeuser) that runs every minute. - Laravel’s scheduler —
php artisan schedule:runevaluates yourroutes/console.phporapp/Console/Kernel.phpdefinitions and decides which tasks are due.
Forge also manages daemons (queue workers, Horizon, Reverb) through Supervisor. Those are long-running processes, not cron. Mixing the two in your mental model is how teams miss failures: a healthy daemon does not prove scheduled tasks ran.
What Forge can tell you:
- The server is reachable
- The daemon process is up (or was restarted)
- The crontab entry still exists
What Forge cannot tell you:
- Whether
schedule:runactually executed this minute - Whether a due task was skipped by
withoutOverlappingforever - Whether a command exited non-zero after the scheduler already logged “Running scheduled command”
- Whether a multi-server setup double-ran or under-ran a job
Common Failure Modes on Forge
The crontab entry disappears or points at the wrong path
After a site rename, PHP version change, or manual server surgery, the Forge cron line can still point at an old release path or a removed PHP binary. Cron fails every minute. Forge UI still shows the old entry as if it were fine. You only notice when daily jobs stop.
Check the live crontab on the box:
sudo -u forge crontab -l
# expect something like:
# * * * * * cd /home/forge/app.com/current && php artisan schedule:run >> /dev/null 2>&1
If the path is wrong, fix it in the Forge UI (Scheduled Jobs) and redeploy confidence with a heartbeat (below).
schedule:run succeeds while tasks silently skip
Laravel’s scheduler can skip work without failing the process:
withoutOverlapping()left a stale mutex in cache/Redis after a killed deployonOneServer()lost the cache lock backend mid-runenvironments(['production'])does not matchAPP_ENVon a staging-like Forge site- Timezone mismatch between server clock,
APP_TIMEZONE, and the cron expression
From Forge’s perspective the cron job “ran.” From the business’s perspective the digest never sent.
Daemon up, scheduler down
Teams monitor Horizon via Forge daemons and assume “background work is covered.” Cron is a separate pipeline. You can have healthy workers draining an empty queue while nightly model:prune, backup:run, and billing reconciliation never fire.
Multi-site / multi-server double-scheduling
Forge makes it easy to clone sites. Clone the server recipe and you often clone the same crontab onto two machines without onOneServer(). Jobs run twice — or, if they fight over locks, appear to run nowhere reliably.
Output discarded to /dev/null
The default Forge recipe often appends >> /dev/null 2>&1. Failures leave no local trail. By the time you SSH in, there is nothing to tail.
How to Monitor Forge Cron Jobs
1. Heartbeat the scheduler itself
Treat “did schedule:run fire this minute?” as a first-class signal. Add a lightweight scheduled task purely to prove the scheduler itself is alive:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::call(fn () => null)
->everyMinute()
->name('scheduler-heartbeat')
->withoutOverlapping();
Crontinel’s package hooks into ScheduledTaskStarting, ScheduledTaskFinished, and ScheduledTaskFailed automatically once installed - no ping URL or config needed. Create a Cron monitor named scheduler-heartbeat with an alert window of a few minutes. If Forge’s crontab dies, PHP breaks after a version bump, or the release path is wrong, this monitor goes silent first - before the weekly report is late.
2. Let the package track critical artisan commands directly
Heartbeats prove the scheduler is alive. They do not prove backup:run finished. For high-value commands, no wrapping is needed - Crontinel’s package tracks every scheduled command automatically:
Schedule::command('backup:run')
->dailyAt('02:00')
->onOneServer()
->withoutOverlapping();
Create a matching Cron monitor for backup:run in the dashboard. If it exits non-zero, hangs past your grace period, or the run never starts at all, Crontinel reports it - no start/success/failure hooks to write or maintain.
3. Do not rely on Forge Heartbeats alone for Laravel schedules
Forge Heartbeats are useful for simple “did this URL get hit?” checks. Laravel schedules have richer failure modes: partial runs, mutex deadlocks, environment gates, and multi-server races. A single generic heartbeat is necessary but not sufficient for production billing, backups, and prune jobs.
Use Forge for server and daemon lifecycle. Use job-aware monitoring for schedule semantics.
4. Keep daemon and cron alerts separate
In Forge:
- Daemons → Supervisor process must stay up (Horizon,
queue:work, Reverb) - Scheduled Jobs → crontab must fire
schedule:run
In your external monitor:
- One check for scheduler heartbeat
- Separate checks for each business-critical command cadence
- Separate checks for queue depth / failed jobs (workers can be “up” while the backlog is on fire)
Practical Forge Checklist
- SSH in and confirm
crontab -lpath matches the current release directory after zero-downtime deploys. - Confirm
php -von the cron line matches the site’s PHP version in Forge. - Log scheduler output somewhere durable during incidents (temporary file or papertrail) instead of only
/dev/null. - After every major deploy, wait two minutes and confirm the external heartbeat still arrives.
- For multi-server apps, require
onOneServer()plus a shared cache/Redis the lock can actually use. - Alert on missed runs, not only on exceptions — most Forge cron pain is absence, not stack traces.
Where Crontinel Fits
Crontinel is built for the gap Forge leaves open: expected cadence, missed-run detection, and quiet failures inside Laravel’s scheduler. Point scheduled tasks (or the official Laravel SDK) at Crontinel, keep Forge managing servers and daemons, and you get paged when the job did not happen — not when the VM happens to be offline. The same logic applies if Forge is running your app under Octane: Octane serves HTTP, but it does not run the scheduler, so keep a separate heartbeat on schedule:run (why).
Install the Laravel package, attach monitors to schedule:run and your critical commands, and route alerts to the same Slack or PagerDuty channel your on-call already watches. Forge keeps the box alive. Crontinel watches the work.