How to Monitor Cron Jobs in Laravel
Laravel has ping methods built in. The task nobody monitors is schedule:run itself, and if it stops, everything stops.
Laravel ships with ping methods on the scheduler, so monitoring individual tasks takes one line each. The part that needs thought is the single cron entry that runs the whole scheduler.
The single point of failure
Every Laravel application has exactly one line in the system crontab, calling schedule:run every minute. Every scheduled task in your application depends on it.
If that line is removed during a server rebuild, if the deploy user's crontab is not restored, or if the PHP binary moves after an upgrade, every scheduled task in your application stops at once. No error is raised anywhere, because the thing that would have raised it never ran.
Monitoring individual tasks catches this too, but only after each task's own interval elapses. A monthly report will not tell you for a month. Put a monitor on schedule:run itself with a tight interval, and you know within minutes.
That is a no-op task whose only purpose is to prove the scheduler is alive. It is the highest-value monitor in a Laravel application and it costs one line.
Monitoring individual tasks
The scheduler exposes several ping methods. Use thenPing for tasks where you want to know it completed, and pingOnFailure for the ones where you want an immediate alert rather than waiting out the interval.
The ping methods require Guzzle, which is present in most Laravel applications but not all of them. If yours is minimal, composer require guzzlehttp/guzzle before the pings will do anything.
There is also thenPingIf, which takes a boolean as its first argument. It is the clean way to avoid pinging from local and staging environments without wrapping everything in conditionals.
withoutOverlapping has a trap
Adding withoutOverlapping prevents a long-running task from starting a second copy of itself. It does this with an atomic lock, and the lock has a default expiry of 24 hours.
If the process is killed rather than exiting cleanly, for example by the OOM killer or a deploy that restarts the container, the lock is not released. The task then refuses to start for up to 24 hours while reporting nothing at all.
Set the expiry to a little more than the task's realistic maximum runtime. This is one of the more common causes of a Laravel task that has silently not run since last Tuesday.
onOneServer needs a shared cache
On multi-server deployments, onOneServer prevents the same task running on every application server. It depends on a shared, locking-capable cache driver, which in practice means Redis, Memcached, database or DynamoDB.
If your cache driver is file or array, onOneServer does nothing and every server runs the task. Three servers means three copies of your nightly billing job.
Verifying what is registered
Run this on the production server rather than locally, because environment-conditional tasks will differ. Comparing the output against your monitors is a quick way to find tasks that exist but have nothing watching them.
Related guides
Rails has a similar structure, covered in monitoring cron jobs in Rails, and plain PHP in monitoring cron jobs in PHP.