To list cron jobs in WordPress with WP-CLI, most people run wp cron event list. It prints hooks and next-run times, but it can’t answer the two questions you have when scheduled work stops in production: which events are overdue, and which events will never do anything because the plugin that owned the hook was removed and nothing is listening to it any more.
The snippet below adds a wp dpc-cron list command. Each event gets a readable “due” column, a count of the callbacks attached to its hook, and a status of ok, overdue or orphaned. You can filter for problems, and --strict returns a non-zero exit code when there are any, so you can run it from a deploy script or a monitoring job.
The snippet
Put it in an mu-plugin (for example wp-content/mu-plugins/dpc-cron-health.php), a small plugin, or a Scripts Organizer PHP block. It only registers anything when WP-CLI is running. The data function works in every context.
<?php
/**
* Plugin Name: DPC Cron Health
* Description: wp dpc-cron list: WP-Cron events with overdue and orphaned status.
*/
function dpc_cron_callback_count( string $hook ): int {
global $wp_filter;
if ( empty( $wp_filter[ $hook ] ) ) {
return 0;
}
return (int) array_sum( array_map( 'count', $wp_filter[ $hook ]->callbacks ) );
}
function dpc_cron_events(): array {
$now = time();
$grace = (int) apply_filters( 'dpc_cron_overdue_grace', 10 * MINUTE_IN_SECONDS );
$events = [];
foreach ( (array) _get_cron_array() as $timestamp => $hooks ) {
if ( ! is_array( $hooks ) ) {
continue;
}
foreach ( $hooks as $hook => $instances ) {
foreach ( (array) $instances as $instance ) {
$ts = (int) $timestamp;
$callbacks = dpc_cron_callback_count( (string) $hook );
$overdue = $ts < $now - $grace;
$orphaned = 0 === $callbacks;
$status = array_keys( array_filter( [ 'overdue' => $overdue, 'orphaned' => $orphaned ] ) );
$events[] = [
'hook' => (string) $hook,
'timestamp' => $ts,
'next_run_gmt' => gmdate( 'Y-m-d H:i:s', $ts ),
'due' => $ts <= $now
? sprintf( '%s ago', human_time_diff( $ts, $now ) )
: sprintf( 'in %s', human_time_diff( $now, $ts ) ),
'schedule' => empty( $instance['schedule'] ) ? 'single' : (string) $instance['schedule'],
'args' => empty( $instance['args'] ) ? '' : (string) wp_json_encode( $instance['args'] ),
'callbacks' => $callbacks,
'overdue' => $overdue,
'orphaned' => $orphaned,
'status' => $status ? implode( ', ', $status ) : 'ok',
];
}
}
}
return $events;
}
function dpc_cron_list_command( array $args, array $assoc_args ): void {
$events = dpc_cron_events();
$problems = count( array_filter( $events, fn( $e ) => $e['overdue'] || $e['orphaned'] ) );
if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'overdue', false ) ) {
$events = array_filter( $events, fn( $e ) => $e['overdue'] );
}
if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'orphaned', false ) ) {
$events = array_filter( $events, fn( $e ) => $e['orphaned'] );
}
$fields = \WP_CLI\Utils\get_flag_value( $assoc_args, 'fields', 'hook,next_run_gmt,due,schedule,callbacks,status' );
$format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' );
\WP_CLI\Utils\format_items( $format, array_values( $events ), $fields );
if ( $problems > 0 && \WP_CLI\Utils\get_flag_value( $assoc_args, 'strict', false ) ) {
\WP_CLI::error( sprintf( '%d cron event(s) are overdue or orphaned.', $problems ) );
}
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command(
'dpc-cron list',
'dpc_cron_list_command',
[
'shortdesc' => 'List WP-Cron events with overdue and orphaned status.',
'synopsis' => [
[ 'type' => 'flag', 'name' => 'overdue', 'optional' => true, 'description' => 'Only show overdue events.' ],
[ 'type' => 'flag', 'name' => 'orphaned', 'optional' => true, 'description' => 'Only show events whose hook has no callback.' ],
[ 'type' => 'flag', 'name' => 'strict', 'optional' => true, 'description' => 'Exit with code 1 if any event is overdue or orphaned.' ],
[ 'type' => 'assoc', 'name' => 'fields', 'optional' => true, 'description' => 'Comma-separated fields to show.' ],
[
'type' => 'assoc',
'name' => 'format',
'optional' => true,
'default' => 'table',
'options' => [ 'table', 'csv', 'json', 'yaml', 'count' ],
],
],
]
);
}
Using it
# Every scheduled event, with status
wp dpc-cron list
# Only the problems
wp dpc-cron list --overdue
wp dpc-cron list --orphaned --fields=hook,args,next_run_gmt
# In a deploy or monitoring script: non-zero exit on any problem
wp dpc-cron list --overdue --strict --format=json || echo "WP-Cron needs attention"
How it works
_get_cron_array()returns thecronoption: timestamps, then hooks, then one entry per set of arguments. WP-CLI’s own cron command reads the same array.dpc_cron_callback_count()looks the hook up in$wp_filter, the global registryadd_action()writes to, and counts every attached callback across all priorities. A count of zero means WP-Cron will fire the hook and nothing will happen.- An event counts as overdue when its timestamp is more than 10 minutes in the past. That margin covers the normal lag on sites where WP-Cron only runs on page loads.
format_items()gives you all of WP-CLI’s output formats, so--format=jsoncan go straight intojqor a monitoring check.--strictcounts problems before the filters run and callsWP_CLI::error(), which exits with code 1.
Customise it
Change the overdue margin with the dpc_cron_overdue_grace filter, for example add_filter( 'dpc_cron_overdue_grace', fn() => HOUR_IN_SECONDS ); on a low-traffic site where WP-Cron only fires on visits. Show the arguments as well with --fields=hook,args,due,status. Delete an orphaned event with wp cron event delete <hook> once you know which plugin it came from.
Gotchas
- Some “orphaned” hooks are false alarms. WP-CLI loads WordPress as a front-end request. A callback added only inside
admin_init, behindis_admin(), or only whenDOING_CRONis set will show 0 callbacks even though the real cron run handles the event. Search the codebase for the hook before you delete anything. Find all functions hooked to a WordPress action helps here. - Many overdue events usually point to the runner, not the jobs. Check whether
DISABLE_WP_CRONistruewithout a server cron callingwp cron event run --due-now, or whether loopback requests are blocked. _get_cron_array()is marked private in core, but core and WP-CLI have relied on its shape for years. Pin your WordPress version in CI if you build alerts on it.- This only reads data. Pair it with other read-only checks, such as listing hidden admin users, in the same post-deploy script.
Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.
0 of 10 votes