wp-plugin-cron — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited wp-plugin-cron (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Scheduled work — daily cleanup, periodic API sync, deferred email send, retry on failed webhook delivery — is a normal part of plugin life. WordPress ships its own cron primitive (wp_schedule_event), and the WooCommerce-bundled Action Scheduler library extends the model into a proper queue. Picking between them and using the chosen one correctly is what this skill covers.
Trigger when ANY of the following is true:
WordPress cron does NOT run on a system schedule. There is no cron daemon waking WP up. Instead:
init, WordPress calls wp_cron() (wp-includes/default-filters.php).wp_cron() registers _wp_cron() on shutdown for normal requests, so the cron spawn does not hurt TTFB as much. With ALTERNATE_WP_CRON, it still uses wp_loaded._wp_cron() checks for due events and makes a non-blocking loopback request to /wp-cron.php, which actually runs the due events.Implications:
daily_cleanup callback runs 90 seconds, the wp-cron.php request takes 90 seconds. Other due events in that batch wait.For real-time precision OR predictable timing, set DISABLE_WP_CRON in wp-config.php and configure system cron to call wp-cron.php every minute:
define( 'DISABLE_WP_CRON', true );* * * * * curl -s https://example.com/wp-cron.php > /dev/null 2>&1This is host-level setup, NOT the plugin's responsibility — but the plugin's docs should mention it for users with timing-sensitive workloads.
// 1. Register a custom interval (only needed for non-default recurrences).
add_filter( 'cron_schedules', static function ( array $schedules ): array {
$schedules['every_six_hours'] = array(
'interval' => 6 * HOUR_IN_SECONDS,
'display' => __( 'Every 6 Hours', 'myplugin' ),
);
return $schedules;
} );
// 2. Schedule on activation (idempotently — see below).
register_activation_hook( __FILE__, static function (): void {
if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );
}
} );
// 3. Wire the callback at runtime (in a 'plugins_loaded' callback or top-level).
add_action( 'myplugin_daily_cleanup', static function (): void {
// The actual work.
myplugin_purge_old_logs();
} );
// 4. Clear on deactivation. wp_unschedule_hook clears ALL events for the hook
// regardless of $args; safer than wp_clear_scheduled_hook($hook, $args)
// which requires the exact args used at schedule time.
register_deactivation_hook( __FILE__, static function (): void {
wp_unschedule_hook( 'myplugin_daily_cleanup' );
} );The cron_schedules filter returns an array; each schedule has interval (seconds) and display (translated label). WP defaults: 'hourly' (3600), 'twicedaily' (43200), 'daily' (86400), 'weekly' (604800).
If you schedule a custom recurrence during activation, the cron_schedules filter must already be registered before the activation callback calls wp_schedule_event(). A filter hidden inside a runtime object that only boots on plugins_loaded can be missing during activation flows.
For critical scheduling, pass $wp_error=true and log or surface failures:
$result = wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup', array(), true );
if ( is_wp_error( $result ) ) {
error_log( 'MyPlugin cron schedule failed: ' . $result->get_error_message() );
}The wp_next_scheduled guard is non-negotiable. Without it, every plugin reactivation creates a duplicate event:
// WRONG — creates a new event every time activation fires
register_activation_hook( __FILE__, function () {
wp_schedule_event( time(), 'daily', 'myplugin_daily_cleanup' );
} );
// After 5 reactivations: 5 events, the callback runs 5 times daily.
// RIGHT
if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );
}wp_next_scheduled( $hook, $args ) returns the timestamp of the next pending event matching the hook + args, or false if none exists. Args matter — events with different $args are distinct events under the same hook. Schedule with the same args you'll check.
For "do this once, soon" (e.g. send a notification after a form submit, defer a heavy compute off the request):
wp_schedule_single_event( time() + 30, 'myplugin_send_followup', array( $user_id, $form_id ) );Use time() + N for a delay; use time() (or time() + 1) to fire as soon as possible. WP de-duplicates: scheduling the same hook + args within the duplicate window around an existing pending event returns false (or WP_Error when $wp_error=true). This is verified in wp_schedule_single_event().
Each site in a multisite network has its own scheduled events. wp_schedule_event writes to the current blog's cron option; wp_unschedule_hook reads from the current blog only. Treat cron as a per-site primitive.
For a network-wide periodic task, two options:
wp-plugin-lifecycle): foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) {
switch_to_blog( $site_id );
if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );
}
restore_current_blog();
}Multisite cron caveat: this skill's authoring environment is single-site. The above is source-derived from wp-includes/cron.php but not end-to-end tested in a real network. Verify before relying.
Action Scheduler is a queue-style background-job library bundled with WooCommerce (and standalone available via Composer). It uses its own DB tables instead of wp_options and adds capabilities WP cron doesn't have.
When to graduate from WP cron to Action Scheduler:
| Need | WP cron | Action Scheduler |
|---|---|---|
| Scheduling 5-10 plugins' worth of events | OK | OK |
| 10,000+ scheduled actions (e.g. one per order) | slow / breaks — cron option grows huge in wp_options, all autoloaded | designed for it |
| Per-action status tracking (pending / running / completed / failed) | none | built-in |
| Built-in retry on failure | manual | built-in |
| Duplicate guards | partial (single-event 10-min de-dup by hook+args) | $unique for hook+group singletons, or exact-args guards with as_has_scheduled_action() |
| Admin UI to inspect queue / re-run failures | none | yes (Tools → Scheduled Actions) |
| Logical grouping of related actions | none | group parameter |
| Graceful concurrency (multiple workers) | no | yes |
Detection in code:
if ( function_exists( 'as_schedule_recurring_action' ) ) {
$supports_unique = ( new ReflectionFunction( 'as_schedule_recurring_action' ) )->getNumberOfParameters() >= 6;
if ( $supports_unique ) {
as_schedule_recurring_action(
time() + DAY_IN_SECONDS, // first run
DAY_IN_SECONDS, // interval
'myplugin_daily_cleanup',
array(), // args
'myplugin', // group (logical bucket)
true // unique in modern Action Scheduler
);
} elseif ( ! as_next_scheduled_action( 'myplugin_daily_cleanup', array(), 'myplugin' ) ) {
as_schedule_recurring_action(
time() + DAY_IN_SECONDS,
DAY_IN_SECONDS,
'myplugin_daily_cleanup',
array(),
'myplugin'
);
}
} else {
// Fall back to native WP cron.
if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );
}
}The corresponding clear:
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( 'myplugin_daily_cleanup', array(), 'myplugin' );
} else {
wp_unschedule_hook( 'myplugin_daily_cleanup' );
}For one-shot deferred work, the parallel pair is as_schedule_single_action() / wp_schedule_single_event(). Modern Action Scheduler's $unique flag handles hook+group singleton de-duplication explicitly; per-entity jobs should use exact-args guards plus idempotent callbacks.
The cost of Action Scheduler: it's a hard dependency. For a small plugin with 1-2 daily events on a low-traffic site, native WP cron is fine. Don't pull in WooCommerce or vendor Action Scheduler for a single hourly cleanup.
Cron callbacks run inline. A 60-second daily_cleanup ties up the whole cron batch for that minute. Two patterns to avoid blocking:
wp_schedule_single_event( time() + 1, 'myplugin_daily_cleanup' ) if more remain.wp_schedule_single_event( time(), 'myplugin_process_item', array( $id ) ) per row. Parallelizes well with Action Scheduler; overkill for native WP cron.Even with wp_next_scheduled guards at schedule time, the callback must be idempotent — manual triggers (spawn_cron), restored backups, WP-CLI wp cron event run, and Action Scheduler retries can all replay events. Two minimal patterns:
// Gate by data state — preferred when the work is per-entity.
add_action( 'myplugin_send_invoice', static function ( int $order_id ): void {
if ( get_post_meta( $order_id, '_invoice_sent', true ) ) return;
myplugin_send( $order_id );
update_post_meta( $order_id, '_invoice_sent', time() );
} );
// Soft lock + last-success gate — for global periodic jobs.
add_action( 'myplugin_daily_cleanup', static function (): void {
$last = (int) get_option( 'myplugin_cleanup_last_success', 0 );
if ( time() - $last < HOUR_IN_SECONDS ) return;
if ( get_transient( 'myplugin_cleanup_lock' ) ) return;
set_transient( 'myplugin_cleanup_lock', 1, 15 * MINUTE_IN_SECONDS );
try {
myplugin_run_cleanup();
update_option( 'myplugin_cleanup_last_success', time(), false );
} finally {
delete_transient( 'myplugin_cleanup_lock' );
}
} );The transient lock and get_option+update_option pattern is non-atomic (TOCTOU race) but good enough for soft idempotency. For a global singleton, modern Action Scheduler's $unique support can help; for per-entity hard idempotency, use a unique-key insert into a custom table as the gate.
wp_cron() on init; since WP 6.9 the normal spawn runs on shutdown (wp_loaded for ALTERNATE_WP_CRON). No traffic = no cron. Use DISABLE_WP_CRON + system cron for timing-critical work.$wp_error=true.wp_unschedule_hook (since WP 4.9, hook+args agnostic).interval (seconds) + display (label).last_success timestamp gate.// WRONG — duplicate events on every reactivation
register_activation_hook( __FILE__, function () {
wp_schedule_event( time(), 'daily', 'myplugin_cleanup' );
} );
// WRONG — args mismatch, deactivation fails to clear the event
register_activation_hook( __FILE__, function () {
wp_schedule_event( time(), 'daily', 'myplugin_cleanup', array( 'mode' => 'fast' ) );
} );
register_deactivation_hook( __FILE__, function () {
wp_clear_scheduled_hook( 'myplugin_cleanup' ); // missing args
} );
// Use wp_unschedule_hook instead.
// WRONG — assumes cron fires at the registered time
wp_schedule_event( time() + 60, 'hourly', 'myplugin_send_invoices_at_4pm' );
// On a low-traffic site this might run at 5:13 PM, 6:48 PM, 9:02 PM...
// WRONG — non-idempotent callback fires twice on retry
add_action( 'myplugin_charge_card', function ( $order_id ) {
stripe_charge( $order_id ); // bills user twice on retry
} );
// RIGHT — gate by data state
add_action( 'myplugin_charge_card', function ( $order_id ) {
if ( get_post_meta( $order_id, '_charged', true ) ) return;
stripe_charge( $order_id );
update_post_meta( $order_id, '_charged', time() );
} );
// WRONG — registering 10,000 actions in wp-cron
foreach ( $orders as $order ) {
wp_schedule_single_event( time(), 'myplugin_process', array( $order->id ) );
}
// 10k events bloats the autoloaded 'cron' option; site grinds.
// Use Action Scheduler for this scale.$network_wide callback args.cron option — at scale (10k+ events) it becomes the autoload bottleneck.wp-action-scheduler.wp cron event list, wp cron event run, wp cron schedule list) — adjacent topic, useful for debugging but separate skill scope.wp_schedule_event / wp_schedule_single_event / wp_next_scheduled / wp_unschedule_hook: wp-includes/cron.phpcron_schedules filter: developer.wordpress.org/reference/hooks/cron_schedules/_wp_cron moved to shutdown): wp-includes/cron.php wp_cron() docblock~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.