Tasks that run by themselves
Some jobs should happen on a timetable: delete old data nightly, refresh a cache every hour, send a weekly digest, publish a post at a set time. WordPress has a built-in scheduler called WP-Cron. A scheduled event is a row in the database that says "at this time, run the hook bakery_nightly_cleanup". Your plugin attaches a function to that hook.
A recurring task
<?php
/**
* Plugin Name: Bakery Cron
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// 1. The work: runs whenever the hook fires.
add_action( 'bakery_hourly_report', function () {
update_option( 'bakery_last_report', gmdate( 'c' ) );
} );
// 2. Schedule it once, on activation.
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'bakery_hourly_report' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'bakery_hourly_report' );
}
} );
// 3. Remove it on deactivation.
register_deactivation_hook( __FILE__, function () {
wp_clear_scheduled_hook( 'bakery_hourly_report' );
} );wp plugin activate bakery-cronPlugin 'bakery-cron' activated.
Success: Activated 1 of 1 plugins.<?php
echo "recurrence: ", wp_get_schedule( 'bakery_hourly_report' ), "\n";
echo "due in about ", round( ( wp_next_scheduled( 'bakery_hourly_report' ) - time() ) / HOUR_IN_SECONDS ), " hour\n";recurrence: hourly
due in about 1 hourwp_schedule_event( $first_run, $recurrence, $hook ) stores the event. The built-in recurrences are hourly, twicedaily, daily and weekly. Always check wp_next_scheduled() first, otherwise each call adds another copy. Schedule on activation rather than on every request, and always clear the event on deactivation, or it stays in the database for a plugin that no longer exists.
Running the event now
WP-CLI shows and runs events, which is how you test without waiting an hour:
wp cron event run bakery_hourly_reportWP-CLI prints the time the event took and "Success: Executed a total of 1 cron event."
The plugin's task stored the time of the run in the option bakery_last_report, so wp option get bakery_last_report now shows a timestamp.
One-off events
wp_schedule_single_event() runs something once, at a time you choose: send a reminder in a day, retry a failed request in five minutes. Pass arguments in an array:
<?php
add_action( 'bakery_send_reminder', function ( int $order_id, string $kind ) {
update_option( 'bakery_reminder_log', "reminder $kind for order $order_id" );
}, 10, 2 );<?php
wp_schedule_single_event( time() + DAY_IN_SECONDS, 'bakery_send_reminder', [ 42, 'pickup' ] );
$timestamp = wp_next_scheduled( 'bakery_send_reminder', [ 42, 'pickup' ] );
echo "scheduled in about ", round( ( $timestamp - time() ) / HOUR_IN_SECONDS ), " hours\n";
// Cancel one event: pass the same hook, time and arguments.
wp_unschedule_event( $timestamp, 'bakery_send_reminder', [ 42, 'pickup' ] );
var_dump( wp_next_scheduled( 'bakery_send_reminder', [ 42, 'pickup' ] ) );scheduled in about 24 hours
bool(false)The event is identified by its hook and its arguments, so the same arguments must be used to find or cancel it.
Custom intervals
For anything other than the four built-in recurrences, add a schedule with the cron_schedules filter:
<?php
add_filter( 'cron_schedules', function ( array $schedules ): array {
$schedules['every_fifteen_minutes'] = [
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => 'Every 15 minutes',
];
return $schedules;
} );<?php
wp_schedule_event( time() + 60, 'every_fifteen_minutes', 'bakery_poll' );
$schedules = wp_get_schedules();
echo $schedules['every_fifteen_minutes']['interval'], " seconds\n";
echo implode( ', ', array_keys( $schedules ) ), "\n";
wp_clear_scheduled_hook( 'bakery_poll' );900 seconds
every_fifteen_minutes, hourly, twicedaily, daily, weeklyWP-Cron is not a real clock
WP-Cron has no background process. On every page request, WordPress checks whether any event is due and, if so, starts wp-cron.php in the background. That has consequences:
- A quiet site runs tasks late, until the next visitor.
- A busy site checks on every request, which adds a little load.
- A task that takes a long time may be cut off by the web server's time limit.
For exact timing and reliability, turn the visitor trigger off and run WP-Cron from the system scheduler every few minutes. In wp-config.php:
define( 'DISABLE_WP_CRON', true );Then add a crontab entry on the server (Linux):
*/5 * * * * cd /var/www/example.com && wp cron event run --due-now >/dev/null 2>&1or curl -s https://example.com/wp-cron.php?doing_wp_cron >/dev/null if WP-CLI is not available. Many hosts offer this in their control panel.
Built-in scheduled tasks
WordPress itself uses WP-Cron to check for updates, publish scheduled posts (publish_future_post), delete expired transients and clean the trash. wp cron event list shows every scheduled event. The schedules (intervals) that are available look like this:
wp cron schedule list --fields=name,interval+-----------------------+----------+
| name | interval |
+-----------------------+----------+
| every_fifteen_minutes | 900 |
| hourly | 3600 |
| twicedaily | 43200 |
| daily | 86400 |
| weekly | 604800 |
+-----------------------+----------+Common mistakes
- Not checking
wp_next_scheduled(), so duplicates accumulate. - Not clearing events on deactivation.
- Expecting exact times from a low-traffic site.
- Long-running work in one event. Process a batch and schedule the next batch.
- Forgetting the argument count (
add_action( ..., 10, 2 )) for events with arguments.
Exercise
Make the cleanup plugin delete all draft posts older than 30 days once a day. Which functions schedule it, and what do you do on deactivation?
Show one possible answer
add_action( 'bakery_daily_cleanup', function () {
$old = get_posts( [
'post_status' => 'draft',
'date_query' => [ [ 'before' => '30 days ago' ] ],
'fields' => 'ids',
'posts_per_page' => 100,
] );
foreach ( $old as $id ) {
wp_delete_post( $id, true );
}
} );
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'bakery_daily_cleanup' ) ) {
wp_schedule_event( time(), 'daily', 'bakery_daily_cleanup' );
}
} );
register_deactivation_hook( __FILE__, function () {
wp_clear_scheduled_hook( 'bakery_daily_cleanup' );
} );Processing 100 at a time keeps each run short; the next day's run continues.
Key points
- WP-Cron stores scheduled events in the database; wp_schedule_event( $time, $recurrence, $hook ) adds a repeating one and wp_schedule_single_event() a one-off.
- Attach your task to the hook with add_action(); the event only stores the hook name and arguments.
- WP-Cron is not a real clock: it runs when someone visits the site, so for exact timing disable it and call wp-cron.php from the system cron.
- Check wp_next_scheduled() before scheduling to avoid duplicates, and wp_clear_scheduled_hook() on deactivation.