Monitor PHP Cron Scripts with Cuckoo
Updated
Many PHP applications still run important work as plain CLI scripts from crontab: php /srv/app/bin/import.php
every hour, a cleanup at night. These scripts fail quietly. A PHP fatal error goes to a log file, memory_limit
ends the script halfway through, or a PHP upgrade removes an extension and the script never gets past line one.
Cron mails the output to a local mailbox at best.
A heartbeat check makes the script report back. If the report is late or says "failed", Cuckoo alerts you by email, Slack, Discord or webhook.
How Cuckoo sees a PHP script
Create one check per script, with the same cron expression and time zone as the crontab entry. Each check has a URL:
https://cuckoo.metastic.ch/ping/YOUR-UUID/startwhen the script startshttps://cuckoo.metastic.ch/ping/YOUR-UUIDwhen it succeedshttps://cuckoo.metastic.ch/ping/YOUR-UUID/failwhen it fails (a POST body of up to 10 KB is stored)https://cuckoo.metastic.ch/ping/YOUR-UUID/<exit code>, where0means success and anything else means failure
Option 1: Wrap the script in crontab
No PHP changes needed. PHP exits with code 255 on a fatal error, and you can call exit(1) yourself on failure:
0 * * * * curl -fsS -m 10 --retry 5 -o /dev/null https://cuckoo.metastic.ch/ping/YOUR-UUID/start; /usr/bin/php /srv/app/bin/import.php; curl -fsS -m 10 --retry 5 -o /dev/null https://cuckoo.metastic.ch/ping/YOUR-UUID/$?
This catches the most cases, including scripts that die before your own code runs (a parse error or a missing extension).
Option 2: Ping from PHP
A small helper with the curl extension and short timeouts. It never throws:
<?php
// lib/heartbeat.php
function heartbeat_ping(string $url, string $suffix = '', ?string $body = null): void
{
if ($url === '') {
return;
}
if (function_exists('curl_init')) {
$ch = curl_init($url . $suffix);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, substr($body, 0, 10000));
}
curl_exec($ch); // handle is freed automatically (PHP 8+)
return;
}
// Fallback without the curl extension (needs allow_url_fopen=On)
$context = stream_context_create(['http' => [
'method' => $body === null ? 'GET' : 'POST',
'header' => "Content-Type: text/plain\r\n",
'content' => $body === null ? '' : substr($body, 0, 10000),
'timeout' => 10,
'ignore_errors' => true,
]]);
@file_get_contents($url . $suffix, false, $context);
}
Then wire it into the script, including the cases a try/catch can't see:
<?php
// bin/import.php
require __DIR__ . '/../lib/heartbeat.php';
const PING_URL = 'https://cuckoo.metastic.ch/ping/YOUR-UUID';
$finished = false;
register_shutdown_function(function () use (&$finished) {
if ($finished) {
return;
}
$error = error_get_last();
$message = $error
? sprintf('%s in %s:%d', $error['message'], $error['file'], $error['line'])
: 'script ended without reporting success';
heartbeat_ping(PING_URL, '/fail', $message);
});
heartbeat_ping(PING_URL, '/start');
try {
run_import(); // your actual work
} catch (Throwable $e) {
heartbeat_ping(PING_URL, '/fail', (string) $e);
$finished = true;
fwrite(STDERR, $e . PHP_EOL);
exit(1);
}
heartbeat_ping(PING_URL);
$finished = true;
exit(0);
Why the shutdown function: fatal errors like "Allowed memory size exhausted", or "Maximum execution time exceeded" after
set_time_limit(), skip catch blocks, but shutdown functions still run. The $finished flag makes sure a normal exit doesn't send
a false failure. If the process is killed with SIGKILL (for example by the OOM killer), nothing in PHP runs. The
missing success ping then triggers the alert after the grace time.
Exit codes matter
Even if you ping from PHP, exit non-zero on failure. Cron wrappers, systemd and container orchestrators all rely
on it, and Option 1 reports it to Cuckoo directly. exit(1) for handled errors is a good convention. PHP uses
255 for fatal errors by itself.
Symfony Console and other frameworks
For a Symfony Console command, return Command::SUCCESS or Command::FAILURE from execute(), then use the
crontab wrapper from Option 1 with bin/console app:import. The exit code is reported as is. Inside the command,
the same heartbeat_ping() helper works too. For Laravel, use the scheduler's built-in ping methods instead.
Schedule and grace advice
- Mirror the crontab line in Cuckoo, for example
0 * * * *with the server's IANA time zone. - Grace time is roughly the longest normal runtime plus a margin. For an hourly import that takes 5 minutes, 15 minutes is reasonable.
- Set a max runtime when a script can hang on a remote API. With the
/startping, Cuckoo flags runs that never finish.
Troubleshooting
- Works by hand, not from cron: cron uses a different
PATHand often a differentphp.ini. Use absolute paths tophpand the script, and comparephp --inioutput. - No pings and no errors:
allow_url_fopenis off and the curl extension is missing. Installphp-curl. - Failure reported on success: an
exit()before$finished = true. Set the flag before every intended exit.
FAQ
Is file_get_contents or the curl extension better for pings?
Both work. The curl extension gives separate connect and total timeouts and doesn't depend on
allow_url_fopen. file_get_contents with a stream context is fine when curl isn't installed.
Will a Cuckoo outage make my script fail?
No. The helper uses short timeouts, ignores errors, and returns nothing. Your script continues whether or not the ping arrives.
Should I use both the crontab wrapper and pings from PHP?
Usually one is enough. The wrapper also catches crashes before PHP code runs. In-script pings let you attach
error details. If you combine them, use separate checks or only send /start from one place.
Related guides
- Monitor APScheduler Jobs in Python with CuckooMonitor APScheduler 3.x jobs with Cuckoo: CronTrigger with timezone, misfire_grace_time and max_instances, plus pings from listeners or the job itself.
- Monitor BullMQ Job Schedulers and Repeatable JobsMonitor BullMQ job schedulers and older repeatable jobs: ping Cuckoo from worker events or the processor, and only alert once attempts are used up.
- Monitor Go Cron Jobs and robfig/cron SchedulesMonitor Go scheduled jobs with Cuckoo: a ping helper using net/http and context timeouts, robfig/cron/v3 with CRON_TZ, and standalone binaries run by cron.
- Monitor node-cron and Croner Jobs in Node.jsMonitor node-cron and Croner scheduled jobs in Node.js with fetch and AbortSignal.timeout pings, and get alerted when the whole Node process goes down.