WordPress has its own scheduler, WP-Cron, for jobs that should run on a timetable: clearing expired data, sending digests, syncing with an API. In this first part of the series you will build a small plugin that schedules a recurring job properly, adds a custom interval, cleans up after itself, and runs reliably on a real server cron. Part 2 covers single events, arguments, and how to inspect and debug scheduled jobs.
Note Updated in November 2026. The plugin below was checked on WordPress 7.1.3 with PHP 8.2.
How WP-Cron differs from a real cron
A system cron runs at exact times whether or not anyone visits your site. WP-Cron does not. On each page load, WordPress checks whether any scheduled job is due and, if so, runs it in a background request. So:
- A job scheduled for 03:00 runs on the first visit after 03:00. On a quiet site that might be hours later.
- On a busy site the check happens on many requests, which adds a little overhead.
For low-traffic sites and anything time-sensitive, you will connect WP-Cron to a real system cron at the end of this article.
The example: a token cleanup job every 15 minutes
A plugin that deletes expired rows from a custom table every 15 minutes. The pattern is the same for any recurring job.
Step 1 Create the plugin and the job
Create wp-content/plugins/wth-token-cleanup/wth-token-cleanup.php:
<?php
/**
* Plugin Name: WTH Token Cleanup
* Description: Deletes expired tokens every 15 minutes with WP-Cron.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
const WTH_CLEANUP_HOOK = 'wth_cleanup_expired_tokens';
// 1. Add a custom interval: WordPress only ships hourly, twicedaily, daily and weekly.
add_filter( 'cron_schedules', function ( array $schedules ): array {
$schedules['wth_every_fifteen_minutes'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 minutes' ),
);
return $schedules;
} );
// 2. The job itself: runs whenever the hook fires.
add_action( WTH_CLEANUP_HOOK, function (): void {
global $wpdb;
$table = $wpdb->prefix . 'wth_tokens';
$deleted = $wpdb->query(
$wpdb->prepare( "DELETE FROM {$table} WHERE expires_at < %s", current_time( 'mysql', true ) )
);
update_option( 'wth_last_cleanup', array( 'time' => time(), 'deleted' => (int) $deleted ), false );
} );
A scheduled job is just an action hook. WP-Cron’s only role is to call do_action( 'wth_cleanup_expired_tokens' ) at the right time.
Step 2 Schedule it once, on activation
Add an activation hook to the same file:
register_activation_hook( __FILE__, function (): void {
if ( ! wp_next_scheduled( WTH_CLEANUP_HOOK ) ) {
wp_schedule_event( time(), 'wth_every_fifteen_minutes', WTH_CLEANUP_HOOK );
}
} );
wp_schedule_event( $timestamp, $recurrence, $hook, $args ) takes:
$timestamp: when the first run is due, as a Unix timestamp.time()means as soon as possible.$recurrence: the name of an interval, such ashourly,twicedaily,daily,weeklyor your own.$hook: the action to run.$args: optional arguments passed to the hook. Part 2 covers these.
Important Always check wp_next_scheduled() first. Without the check, every call adds another copy of the event. In our test, two calls with different start times left two copies of the same job in the schedule, so it ran twice as often. Many older tutorials schedule on init, which runs on every page load. Scheduling on activation, with the guard, is both cheaper and safer.
Two wp_schedule_event() calls without the guard, then the cron list (test site: WordPress 7.1.3, PHP 8.2):
$ wp eval 'wp_schedule_event( time(), "hourly", "wth_demo_job" ); wp_schedule_event( time() + 60, "hourly", "wth_demo_job" );'
$ wp cron event list --fields=hook,next_run_relative,recurrence | grep -E '^hook|wth_demo_job'
hook next_run_relative recurrence
wth_demo_job now 1 hour
wth_demo_job 59 seconds 1 hour
Step 3 Unschedule on deactivation
If you deactivate the plugin without removing the event, WordPress keeps trying to run a hook that no longer exists. Clear it:
register_deactivation_hook( __FILE__, function (): void {
wp_clear_scheduled_hook( WTH_CLEANUP_HOOK );
} );
wp_clear_scheduled_hook() removes every scheduled copy of the hook and returns how many it removed. To remove a single occurrence instead, use wp_unschedule_event( wp_next_scheduled( WTH_CLEANUP_HOOK ), WTH_CLEANUP_HOOK ).
Step 4 Check that it is scheduled
Activate the plugin, then check from code or WP-CLI:
$next = wp_next_scheduled( WTH_CLEANUP_HOOK ); // Unix timestamp, or false
$event = wp_get_scheduled_event( WTH_CLEANUP_HOOK ); // ->schedule, ->interval, ->timestamp
wp cron event list --fields=hook,next_run_relative,recurrence
wp cron event run wth_cleanup_expired_tokens
In our test the event reported the schedule wth_every_fifteen_minutes with an interval of 900 seconds, and running the hook called the job once.
The plugin activated with five tokens, three of them expired (test site: WordPress 7.1.3, PHP 8.2):
$ wp eval 'var_dump( wp_next_scheduled( "wth_cleanup_expired_tokens" ) ); print_r( wp_get_scheduled_event( "wth_cleanup_expired_tokens" ) );'
int(1794514649)
stdClass Object
(
[hook] => wth_cleanup_expired_tokens
[timestamp] => 1794514649
[schedule] => wth_every_fifteen_minutes
[args] => Array
(
)
[interval] => 900
)
$ wp cron event list --fields=hook,next_run_relative,recurrence | grep -E '^hook|wth_'
hook next_run_relative recurrence
wth_cleanup_expired_tokens now 15 minutes
$ wp cron event run wth_cleanup_expired_tokens
Executed the cron event 'wth_cleanup_expired_tokens' in 0.005s.
Success: Executed a total of 1 cron event.
$ wp option get wth_last_cleanup --format=json
{"time":1794514650,"deleted":3}
Tip If wp_schedule_event() fails, pass true as the fifth argument to get a WP_Error instead of false. Scheduling with an interval that is not registered returns the error code invalid_schedule, which usually means your cron_schedules filter did not run.
Scheduling with an interval that is not registered, with the fifth argument set to true (test site: WordPress 7.1.3, PHP 8.2):
$ wp eval '$r = wp_schedule_event( time(), "every_ten_seconds", "wth_demo_job2", array(), true ); echo get_class( $r ), ": ", $r->get_error_code(), " - ", $r->get_error_message(), "\n";'
WP_Error: invalid_schedule - Event schedule does not exist.
Step 5 Run WP-Cron from a real cron
To run jobs on time regardless of traffic, stop WordPress triggering cron on page loads and call it from the server instead. Add this to wp-config.php:
define( 'DISABLE_WP_CRON', true );
Then add a system cron job (crontab -e) that runs every five minutes. With WP-CLI:
*/5 * * * * cd /var/www/html && wp cron event run --due-now --quiet
Without WP-CLI, request wp-cron.php directly:
*/5 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1
Many hosts offer a cron setting in their control panel if you cannot edit crontab.
Warning Once DISABLE_WP_CRON is set, nothing runs scheduled jobs until the system cron is in place, including WordPress’s own update checks and scheduled posts. Add the cron job first, then the constant.
Five mistakes that stop WP-Cron jobs running properly
- Scheduling on every page load without
wp_next_scheduled(), which piles up duplicate events. - Using a custom interval name before the
cron_schedulesfilter is added. - Forgetting to clear the event on deactivation.
- Expecting exact timing on a low-traffic site without a real cron.
- Doing slow work, such as large imports, in a single cron run. Split it into batches, or use a queue such as Action Scheduler.
Where Part 2 picks up
You now have a recurring job that schedules itself once, cleans up after itself, and can run on time from a system cron. In Part 2 you will schedule one-off events, pass arguments, and build a small admin page to see every scheduled job.
Official reference Cron in the Plugin Handbook · wp_schedule_event() · Hooking WP-Cron into the system task scheduler · wp cron
2 Comments