Claude Skill

wp-plugin-cron

Designs and reviews scheduled/background work in WordPress plugins: wp_schedule_event, wp_schedule_single_event, cron_schedules, wp_next_scheduled guards, activation scheduling, deactivation cleanup, WP-Cron pseudo-cron timing, DISABLE_WP_CRON/system cron, multisite per-blog cron

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-plugin-scaffold_wp-plugin-cron-52f6020.zip · 7 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/plugin-scaffold/wp-plugin-cron
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

WordPress plugin: cron & background jobs

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.

When to use this skill

Trigger when ANY of the following is true:

  • Scheduling a periodic task (daily option cleanup, hourly token refresh, weekly digest email).
  • Deferring work off the request thread (e.g. send email after the form submission returns).
  • Debugging "my cron event was registered but never fires" or "fires multiple times" or "fires hours late".
  • Deciding between native WP cron and Action Scheduler for a non-trivial background workload.
  • Reviewing a plugin's activation / deactivation hooks for cron schedule + clear correctness.

Mental model — WP cron is pseudo-cron, not real cron

WordPress cron does NOT run on a system schedule. There is no cron daemon waking WP up. Instead:

  1. Page request comes in.
  2. On init, WordPress calls wp_cron() (wp-includes/default-filters.php).
  3. Since WP 6.9, 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.
  4. _wp_cron() checks for due events and makes a non-blocking loopback request to /wp-cron.php, which actually runs the due events.

In WordPress 7.1, the cron spawn passes its resolved cron URL as the second argument to https_local_ssl_verify. Existing one-argument callbacks continue to work, while a filter that needs host-specific local TLS policy can register for two arguments. Keep verification exceptions exact and development/host specific; do not disable TLS verification globally for cron.

Implications:

  • No traffic = no cron. A site with 5 visitors/day fires cron events 5 times/day, max. A "daily" event on a low-traffic site might run every 3 days.
  • Late firing is normal. An "hourly" event scheduled at noon may fire at 12:47 if that's when the next visitor lands.
  • Concurrent visitors can race. WP has internal locking but it's best-effort; on a busy site, two requests may both attempt to spawn the same event before the lock takes effect.
  • Long-running events block the loopback. If your 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>&1

This is host-level setup, NOT the plugin's responsibility — but the plugin's docs should mention it for users with timing-sensitive workloads.

Native WP cron — the basic API

Register custom intervals before scheduling, guard recurring events with the exact hook+args through wp_next_scheduled(), check WP_Error results for important jobs, wire callbacks on every runtime request, and clear owned hooks on deactivation. Use wp_schedule_single_event() for one-shot deferred work.

Read references/native-wp-cron-patterns.md for the complete activation/runtime/deactivation example, error handling, argument-sensitive deduplication, and one-shot scheduling.

Multisite — cron is per-blog

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:

  1. Schedule per site at activation (network activation iterates sites, see 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();
    }
    
  2. Schedule once on the main blog if the work is genuinely site-wide (writes to network options, not per-blog data). Document the choice — future maintainers will assume per-site otherwise.

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 — when WP cron is not enough

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
Retry after callback failure manual explicit; failed one-off actions are not auto-retried
Duplicate guards partial (single-event 10-min de-dup by hook+args) AS 4.0 $unique for exact hook+group+encoded-args identities; exact-args checks for mixed older runtimes
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(). Action Scheduler 4.0's DBStore makes $unique argument-aware, so canonical per-entity args can be part of the queue identity. Action Scheduler 3.x did not include args; integrations supporting unknown older active copies should use exact-args guards. Every version still needs 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.

Long-running work and idempotency

Cron callbacks run inline. A 60-second daily_cleanup ties up the whole cron batch for that minute. Two patterns to avoid blocking:

  • Chunk and re-schedule: process N rows, then wp_schedule_single_event( time() + 1, 'myplugin_daily_cleanup' ) if more remain.
  • Defer per-item to single events: 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 manually or explicitly scheduled 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. Action Scheduler 4.0's $unique support can suppress an exact queue identity, but for per-entity hard idempotency use a unique-key insert into an owned table or a provider-enforced idempotency key as the gate.

Critical rules

  • WP cron is pseudo-cron. WordPress calls 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.
  • Always guard schedule with wp_next_scheduled to prevent duplicate events on reactivation.
  • Check scheduling failures for custom intervals or important jobs by passing $wp_error=true.
  • Always pair schedule (activation) with clear (deactivation) using wp_unschedule_hook (since WP 4.9, hook+args agnostic).
  • Custom intervals via cron_schedules filter — return an array with interval (seconds) + display (label).
  • Cron is per-blog in multisite — schedule per-site if the work is per-site.
  • Make callbacks idempotent. Data-state check, soft lock, or last_success timestamp gate.
  • Long-running work goes in chunks (re-schedule a single event after a batch) — don't block the worker.
  • Graduate to Action Scheduler when you need queue semantics: 10k+ actions, status tracking, explicit retry workflows, duplicate guards, admin UI. Don't pull it in for one-off uses.

Common mistakes

// 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.

Cross-references

  • Run wp-plugin-lifecycle for the activation-schedule / deactivation-clear pattern in full lifecycle context — including multisite-aware $network_wide callback args.
  • Run wp-plugin-options-storage for the warning about the autoloaded cron option — at scale (10k+ events) it becomes the autoload bottleneck.
  • Run wp-security-audit on cron callbacks — they run with no current user, so capability checks based on a "logged in user" don't work. Treat persisted args / IDs as untrusted input.
  • Run wp-action-scheduler once the design graduates to Action Scheduler — this skill only covers the decision point and minimal fallback pattern.

What this skill does NOT cover

  • Action Scheduler internal architecture, queue tables, runner process, WP-CLI commands, and 4.0 API details — covered by wp-action-scheduler.
  • WP-CLI cron commands (wp cron event list, wp cron event run, wp cron schedule list) — adjacent topic, useful for debugging but separate skill scope.
  • External queue systems (Redis Queue, AWS SQS, Beanstalkd) integrated into WP — viable for ultra-high-throughput plugins but out of WP-native scope.
  • Server-side cron daemon configuration.

References

Files (wp-agent-skills)
  • references
    • native-wp-cron-patterns.md 2.6 KB
      # Native WordPress cron patterns
      
      Use these patterns after the main skill establishes when native WP-Cron is the right primitive.
      
      ## Recurring event lifecycle
      
      ```php
      // Register a custom interval before code attempts to schedule it.
      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;
      } );
      
      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' );
          }
      } );
      
      // Register callbacks on every runtime request, not only during activation/admin.
      add_action( 'myplugin_daily_cleanup', static function (): void {
          myplugin_purge_old_logs();
      } );
      
      register_deactivation_hook( __FILE__, static function (): void {
          // Clears all events for this owned hook, regardless of args.
          wp_unschedule_hook( 'myplugin_daily_cleanup' );
      } );
      ```
      
      Each `cron_schedules` entry has `interval` in seconds and a translated `display`. WordPress defaults include `hourly`, `twicedaily`, `daily`, and `weekly`. A custom recurrence filter hidden in an object that boots only later can be absent during activation, making scheduling fail.
      
      ## Check failures
      
      Pass `$wp_error = true` when scheduling is operationally important:
      
      ```php
      $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() );
      }
      ```
      
      Do not log sensitive args or payloads with the failure.
      
      ## Argument-sensitive idempotency
      
      `wp_next_scheduled( $hook, $args )` returns the next matching timestamp or `false`. Args are part of identity: use the exact same ordered values at schedule, query, and clear boundaries.
      
      ```php
      $args = array( $site_id );
      
      if ( ! wp_next_scheduled( 'myplugin_daily_cleanup', $args ) ) {
          wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup', $args );
      }
      ```
      
      This prevents ordinary duplicate scheduling but is not an exactly-once execution guarantee. Keep the callback idempotent.
      
      ## One-shot deferred work
      
      ```php
      wp_schedule_single_event(
          time() + 30,
          'myplugin_send_followup',
          array( $user_id, $form_id )
      );
      ```
      
      WordPress rejects the same hook+args within its duplicate window around an existing single event. Check `false`/`WP_Error` and remember that due time is not a delivery guarantee; traffic or a system-cron runner still has to trigger WP-Cron.
      
  • SKILL.md 15.2 KB
    ---
    name: wp-plugin-cron
    description: >-
      Designs and reviews scheduled/background work in WordPress
      plugins: wp_schedule_event, wp_schedule_single_event, cron_schedules,
      wp_next_scheduled guards, activation scheduling, deactivation cleanup,
      WP-Cron pseudo-cron timing, DISABLE_WP_CRON/system cron, multisite
      per-blog cron, idempotent callbacks, chunking, and Action Scheduler
      graduation. Use when adding scheduled jobs, debugging late/duplicate
      cron events, or deciding between WP cron and Action Scheduler.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.5 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # WordPress plugin: cron & background jobs
    
    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.
    
    ## When to use this skill
    
    Trigger when ANY of the following is true:
    
    - Scheduling a periodic task (daily option cleanup, hourly token refresh, weekly digest email).
    - Deferring work off the request thread (e.g. send email after the form submission returns).
    - Debugging "my cron event was registered but never fires" or "fires multiple times" or "fires hours late".
    - Deciding between native WP cron and Action Scheduler for a non-trivial background workload.
    - Reviewing a plugin's activation / deactivation hooks for cron schedule + clear correctness.
    
    ## Mental model — WP cron is pseudo-cron, not real cron
    
    WordPress cron does NOT run on a system schedule. There is no `cron` daemon waking WP up. Instead:
    
    1. Page request comes in.
    2. On `init`, WordPress calls `wp_cron()` (`wp-includes/default-filters.php`).
    3. Since WP 6.9, `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`.
    4. `_wp_cron()` checks for due events and makes a non-blocking loopback request to `/wp-cron.php`, which actually runs the due events.
    
    In WordPress 7.1, the cron spawn passes its resolved cron URL as the second
    argument to `https_local_ssl_verify`. Existing one-argument callbacks continue
    to work, while a filter that needs host-specific local TLS policy can register
    for two arguments. Keep verification exceptions exact and development/host
    specific; do not disable TLS verification globally for cron.
    
    Implications:
    
    - **No traffic = no cron.** A site with 5 visitors/day fires cron events 5 times/day, max. A "daily" event on a low-traffic site might run every 3 days.
    - **Late firing is normal.** An "hourly" event scheduled at noon may fire at 12:47 if that's when the next visitor lands.
    - **Concurrent visitors can race.** WP has internal locking but it's best-effort; on a busy site, two requests may both attempt to spawn the same event before the lock takes effect.
    - **Long-running events block the loopback.** If your `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 );
    ```
    ```cron
    * * * * * curl -s https://example.com/wp-cron.php > /dev/null 2>&1
    ```
    
    This is host-level setup, NOT the plugin's responsibility — but the plugin's docs should mention it for users with timing-sensitive workloads.
    
    ## Native WP cron — the basic API
    
    Register custom intervals before scheduling, guard recurring events with the exact hook+args through `wp_next_scheduled()`, check `WP_Error` results for important jobs, wire callbacks on every runtime request, and clear owned hooks on deactivation. Use `wp_schedule_single_event()` for one-shot deferred work.
    
    Read [references/native-wp-cron-patterns.md](references/native-wp-cron-patterns.md) for the complete activation/runtime/deactivation example, error handling, argument-sensitive deduplication, and one-shot scheduling.
    
    ## Multisite — cron is per-blog
    
    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:
    
    1. **Schedule per site at activation** (network activation iterates sites, see `wp-plugin-lifecycle`):
       ```php
       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();
       }
       ```
    2. **Schedule once on the main blog** if the work is genuinely site-wide (writes to network options, not per-blog data). Document the choice — future maintainers will assume per-site otherwise.
    
    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 — when WP cron is not enough
    
    [Action Scheduler](https://actionscheduler.org) 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 |
    | Retry after callback failure | manual | explicit; failed one-off actions are not auto-retried |
    | Duplicate guards | partial (single-event 10-min de-dup by hook+args) | AS 4.0 `$unique` for exact hook+group+encoded-args identities; exact-args checks for mixed older runtimes |
    | 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:
    
    ```php
    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:
    
    ```php
    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()`. Action Scheduler 4.0's DBStore makes `$unique` argument-aware, so canonical per-entity args can be part of the queue identity. Action Scheduler 3.x did not include args; integrations supporting unknown older active copies should use exact-args guards. Every version still needs 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.
    
    ## Long-running work and idempotency
    
    Cron callbacks run inline. A 60-second `daily_cleanup` ties up the whole cron batch for that minute. Two patterns to avoid blocking:
    
    - **Chunk and re-schedule**: process N rows, then `wp_schedule_single_event( time() + 1, 'myplugin_daily_cleanup' )` if more remain.
    - **Defer per-item to single events**: `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 manually or explicitly scheduled Action Scheduler retries can all replay events. Two minimal patterns:
    
    ```php
    // 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. Action Scheduler 4.0's `$unique` support can suppress an exact queue identity, but for per-entity hard idempotency use a unique-key insert into an owned table or a provider-enforced idempotency key as the gate.
    
    ## Critical rules
    
    - **WP cron is pseudo-cron.** WordPress calls `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.
    - **Always guard schedule with `wp_next_scheduled`** to prevent duplicate events on reactivation.
    - **Check scheduling failures** for custom intervals or important jobs by passing `$wp_error=true`.
    - **Always pair schedule (activation) with clear (deactivation)** using `wp_unschedule_hook` (since WP 4.9, hook+args agnostic).
    - **Custom intervals via `cron_schedules` filter** — return an array with `interval` (seconds) + `display` (label).
    - **Cron is per-blog in multisite** — schedule per-site if the work is per-site.
    - **Make callbacks idempotent.** Data-state check, soft lock, or `last_success` timestamp gate.
    - **Long-running work goes in chunks** (re-schedule a single event after a batch) — don't block the worker.
    - **Graduate to Action Scheduler** when you need queue semantics: 10k+ actions, status tracking, explicit retry workflows, duplicate guards, admin UI. Don't pull it in for one-off uses.
    
    ## Common mistakes
    
    ```php
    // 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.
    ```
    
    ## Cross-references
    
    - Run **`wp-plugin-lifecycle`** for the activation-schedule / deactivation-clear pattern in full lifecycle context — including multisite-aware `$network_wide` callback args.
    - Run **`wp-plugin-options-storage`** for the warning about the autoloaded `cron` option — at scale (10k+ events) it becomes the autoload bottleneck.
    - Run **`wp-security-audit`** on cron callbacks — they run with no current user, so capability checks based on a "logged in user" don't work. Treat persisted args / IDs as untrusted input.
    - Run **`wp-action-scheduler`** once the design graduates to Action Scheduler — this skill only covers the decision point and minimal fallback pattern.
    
    ## What this skill does NOT cover
    
    - Action Scheduler internal architecture, queue tables, runner process, WP-CLI commands, and 4.0 API details — covered by `wp-action-scheduler`.
    - WP-CLI cron commands (`wp cron event list`, `wp cron event run`, `wp cron schedule list`) — adjacent topic, useful for debugging but separate skill scope.
    - External queue systems (Redis Queue, AWS SQS, Beanstalkd) integrated into WP — viable for ultra-high-throughput plugins but out of WP-native scope.
    - Server-side cron daemon configuration.
    
    ## References
    
    - WP Cron Handbook: [developer.wordpress.org/plugins/cron/](https://developer.wordpress.org/plugins/cron/)
    - `wp_schedule_event` / `wp_schedule_single_event` / `wp_next_scheduled` / `wp_unschedule_hook`: `wp-includes/cron.php`
    - `cron_schedules` filter: [developer.wordpress.org/reference/hooks/cron_schedules/](https://developer.wordpress.org/reference/hooks/cron_schedules/)
    - WP 6.9 cron change (`_wp_cron` moved to `shutdown`): `wp-includes/cron.php` `wp_cron()` docblock
    - WP 7.1 cron TLS filter context: `wp-includes/cron.php` `spawn_cron()`
    - Action Scheduler: [actionscheduler.org](https://actionscheduler.org), [bundled in WooCommerce](https://github.com/woocommerce/action-scheduler)
    - Official documentation: <https://developer.wordpress.org/reference/functions/wp_schedule_event/>
    - Official documentation: <https://developer.wordpress.org/reference/functions/wp_schedule_single_event/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related