Why tasks fail silently
Laravel's scheduler runs as a single cron entry:
* * * * * cd /srv/app && php artisan schedule:run >> /dev/null 2>&1If that cron stops running — because cron is down, the filesystem is full, the deploy script broke the crontab, or schedule:run crashes before reaching your task — every scheduled task in your app stops. And you'll find out the next time a report is missing or a subscription didn't renew.
Option 1 — Dead-man's switch per task
Services like Healthchecks.io, Cronitor, and Oh Dear give you URLs that expect pings on a schedule. Miss a ping, get paged.
use Illuminate\Support\Facades\Schedule;
Schedule::command('app:generate-invoices')
->dailyAt('01:00')
->onOneServer()
->withoutOverlapping(10)
->pingOnSuccess('https://hc-ping.com/xxxxxxxx-invoices-start')
->pingOnSuccess('https://hc-ping.com/xxxxxxxx-invoices-done')
->onFailure(fn () => logger()->error('Invoices job failed'));Works reliably. The downside: one URL per task (tedious at 20+ tasks), and you only learn that a task failed — not why, not the stack trace, not the duration trend.
Option 2 — Scheduler events
Laravel dispatches events for every scheduled task lifecycle. Listen and log to any destination.
use Illuminate\Console\Events\ScheduledTaskStarting;
use Illuminate\Console\Events\ScheduledTaskFinished;
use Illuminate\Console\Events\ScheduledTaskFailed;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(ScheduledTaskFinished::class, function ($event) {
logger()->info('Scheduled task finished', [
'task' => $event->task->command ?? $event->task->description,
'runtime' => $event->runtime,
]);
});
Event::listen(ScheduledTaskFailed::class, function ($event) {
logger()->error('Scheduled task failed', [
'task' => $event->task->command ?? $event->task->description,
'exception' => $event->exception->getMessage(),
]);
});
}Pipes through any Laravel log channel. Flexible, but you still need a destination that indexes, groups, and alerts on these log lines — raw logs in a file aren't a monitoring system.
Prevent overlap and split across servers
Two chainable calls every production app should use:
Schedule::command('reports:generate')
->hourly()
// Only one server in the cluster runs it — needs Redis/Memcached cache
->onOneServer()
// If the task is still running from last hour, skip this invocation.
// The integer is a lock-timeout in minutes — CRITICAL: without it a crashed
// task holds the lock forever and blocks every future run.
->withoutOverlapping(120);What to alert on
In order of value:
- Scheduler didn't run at all — highest-priority. Use a dead-man's switch on the schedule:run command itself, not just individual tasks.
- Task failed — group by task name, alert on threshold (e.g. 3 failures in 1 hour), not every occurrence.
- Task duration spiked — the report job that used to take 30s now takes 8 minutes. Use p95 trending, not single-invocation thresholds.
- Task didn't run on expected cadence — hourly task missed an hour. Needs history, not just current-state.
THE EASY WAY
NightOwl records every scheduled task with full history
NightOwl's scheduled-task watcher logs every invocation with start time, end time, duration, exit code, and exception. You see per-task history (processed / failed / skipped), duration trending, and exception capture on one page — no custom event listeners, no separate service per task.
Alert channels (Slack, Discord, Email, Webhook) handle threshold-based alerts so a single flaky task doesn't spam your channel.
composer require nightowl/agent
php artisan nightowl:installTelemetry writes to your PostgreSQL. Works on every driver Laravel's scheduler supports.