Moodle cron fails silently more often than you'd expect. This guide explains how to detect those failures reliably using an external heartbeat monitor, and walks through the code that implements it as a Moodle plugin.

Why Moodle’s built-in cron status is not enough

Moodle shows a “Last cron run” timestamp in Site administration > Server > Tasks > Scheduled tasks. That value is written when a cron run starts, not when it finishes. A run that hits a PHP fatal error, exhausts available memory, or gets stuck on a locked task still updates the timestamp before it stops.

The result: the admin panel shows a recent timestamp, everything looks normal, but no tasks have completed for hours.

The dead man’s switch pattern

An external cron monitor works as a dead man’s switch:

  1. At the end of a successful cron run, your server sends a signal — a simple HTTP GET request — to a URL that the monitoring service provides.
  2. The service expects to receive that signal on a schedule you define.
  3. If the signal does not arrive within the expected window, the service sends an alert.

The monitoring service has no access to your server. It only waits for the ping.

Why a scheduled Moodle task doesn’t work here

The obvious approach would be to add a scheduled task that runs last and fires the ping. Moodle doesn’t support that. There is no “cron finished” event, and you cannot configure a task to run after all others.

More fundamentally: if cron is broken, a scheduled task inside that cron cannot reliably fire. You need the monitor to live outside of Moodle’s task scheduler.

The approach: a wrapper script

The solution is to replace the direct cron invocation with a pipe:

php /var/www/moodle/admin/cli/cron.php 2>&1 | \
  php /var/www/moodle/admin/tool/heartbeat/cli/cron.php

The 2>&1 merges stderr into stdout so that PHP errors are captured alongside normal output. The wrapper script receives everything on stdin, inspects it, and fires the appropriate ping.

The key check

Moodle’s cron script writes one predictable string when it completes successfully:

Cron script completed correctly

The wrapper reads stdin and looks for that string:

$input = file_get_contents('php://stdin');

if (strpos($input, 'Cron script completed correctly' {
    ping_cron_monitor('success');
} else {
    ping_cron_monitor('failure');
    send_logs_email($input);
}

If the string is absent — because cron crashed, was killed, or timed out — the failure branch runs: it fires the failure ping and emails the full cron log to the configured recipients.

Sending the ping

The ping itself is a GET request to a configurable URL. Inside a Moodle CLI script you have the full Moodle stack available, so you can use Moodle’s own curl class:

require_once($CFG->libdir . '/filelib.php');

$curl = new curl();
$curl->get($url);

Having the URL configurable from within Moodle admin — rather than hardcoded in a shell script — means an administrator can change the monitoring endpoint without touching the server.

Storing the URL as a Moodle setting

A single admin settings page handles all configuration. The success and failure URLs are stored as plugin config values:

$settings->add(new admin_setting_configtext(
    'tool_heartbeat/cron_monitor_success_url',
    new lang_string('cron_monitor_success_url', 'tool_heartbeat'),
    new lang_string('cron_monitor_success_url_desc', 'tool_heartbeat'),
    '',
    PARAM_URL
;

In the wrapper, the URL is retrieved at runtime:

$url = get_config('tool_heartbeat', 'cron_monitor_success_url');
$curl->get($url);

What the monitoring service does with the ping

You configure a monitor on any dead man’s switch service that accepts HTTP pings. Set the expected interval to match your cron schedule and add a grace period long enough to cover normal load variation. When pings stop arriving, the service sends an alert through whatever channel you choose — email, SMS, webhook.

Edge cases

OOM kills: If PHP is terminated by the operating system before producing output, the pipe closes without the success string. The monitoring service will not receive a ping within the expected window and will alert. The email log in this case will be empty or partial — check dmesg for OOM killer entries.

Parallel cron runs: The wrapper applies only to the CLI invocation managed by the system cron job. If you run multiple parallel cron processes or use a cron daemon, each invocation needs its own pipe.

Reading stdin: file_get_contents('php://stdin') works in most setups, but on some systems the stream is not immediately available. A fallback using fopen and fgets is worth adding for robustness:

$input = file_get_contents('php://stdin');

if (empty($input {
    $f = fopen('php://stdin', 'r');
    $input = '';
    while ($line = fgets($f {
        $input .= $line;
    }
    fclose($f);
}

Ready-made implementation

We implemented this as a Moodle admin tool plugin (tool_heartbeat), compatible with Moodle 3.9 and later. It includes the wrapper CLI script, all admin settings, optional email notifications with the full cron log on failure, and a master on/off toggle. Contact Solin if you want to use it directly.

Solin builds and maintains custom Moodle and Totara plugins, including the Cron Heartbeat plugin.

Contact us