Cuckoo

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/start when the script starts
  • https://cuckoo.metastic.ch/ping/YOUR-UUID when it succeeds
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/fail when it fails (a POST body of up to 10 KB is stored)
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/<exit code>, where 0 means 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 /start ping, Cuckoo flags runs that never finish.

Troubleshooting

  • Works by hand, not from cron: cron uses a different PATH and often a different php.ini. Use absolute paths to php and the script, and compare php --ini output.
  • No pings and no errors: allow_url_fopen is off and the curl extension is missing. Install php-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

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.