Run WP-Cron on Every Multisite Site with WP-CLI

To run WP-Cron across a WordPress Multisite network, run wp cron event run --due-now once per site with that site’s --url, because each subsite keeps its own cron queue. A plain wp cron event run --due-now in the server crontab only processes the main site. Below is a small must-use plugin that adds a wp multisite-cron run command to do this, counts failures and exits non-zero when any site fails.

The scene this comes from is a familiar one on Multisite hosting: the server crontab runs WP-Cron every few minutes, the main site’s scheduled posts and cleanups go out on time, and a subsite owner reports that their scheduled post never published. Nothing in the cron log looks wrong, because the cron job did exactly what it was told. It just never looked at the subsite.

Why doesn’t wp cron event run –due-now cover every site?

WordPress stores scheduled events in the cron option. On Multisite every site has its own options table, so every site has its own cron option and its own list of due events. WP-CLI loads WordPress as one site per process. Without --url, that is the network’s main site, so wp cron event run --due-now runs the main site’s due events and nothing else.

This matters most when wp-config.php contains define( 'DISABLE_WP_CRON', true );, which is the usual setup once a server cron job takes over. That constant stops page loads from triggering WP-Cron on every site in the network, so the server cron job becomes the only thing running events. If it only covers the main site, subsites silently stop running scheduled posts, plugin cleanups and update checks. The WordPress developer handbook describes the single-site version of this setup; Multisite needs the per-site step on top.

What are the options for running cron on every subsite?

  • A shell loop. wp site list --field=url piped into a loop that calls wp cron event run --due-now --url=.... It works, and most guides stop here. It is harder to keep consistent across servers, and failures are easy to lose inside the loop.
  • The --network flag. The WP-CLI documentation for wp cron event run lists --network (“Run hooks across all sites in a multisite installation”), but marks it as not part of a stable release yet; you need the nightly build. WP-CLI 2.12, the stable version on my own WordPress server, doesn’t have it. Once it ships in a stable release, it is the simplest answer.
  • A WP-CLI command in a must-use plugin. This is what my runbook uses: one command that lists active sites, runs each site’s due events in that site’s context, prints progress and returns a real exit code. It lives with the WordPress install, so the crontab line stays one short command.

How do I add the multisite-cron command?

Create the file wp-content/mu-plugins/multisite-cron-runner.php. Create the mu-plugins directory if it doesn’t exist. Must-use plugins load automatically and don’t need activating in wp-admin, which also means a site admin can’t switch this off by accident.

<?php
/**
 * Plugin Name: Multisite Cron Runner
 * Description: Adds a WP-CLI command to run due cron events for every active site in a WordPress Multisite network.
 * Version: 1.1.0
 * Requires PHP: 7.4
 */

if ( ! defined( 'WP_CLI' ) || ! WP_CLI || ! is_multisite() ) {
	return;
}

WP_CLI::add_command(
	'multisite-cron run',
	function () {
		$sites = get_sites(
			array(
				'number'   => 0,
				'deleted'  => 0,
				'archived' => 0,
				'spam'     => 0,
			)
		);

		if ( empty( $sites ) ) {
			WP_CLI::warning( 'No active sites found.' );
			return;
		}

		$total   = count( $sites );
		$success = 0;
		$failed  = 0;

		WP_CLI::log( sprintf( 'Running due cron events for %d sites.', $total ) );

		foreach ( $sites as $index => $site ) {
			$url = get_site_url( (int) $site->blog_id );

			WP_CLI::log(
				sprintf( '[%d/%d] %s', $index + 1, $total, $url )
			);

			$result = WP_CLI::runcommand(
				'cron event run --due-now --url=' . escapeshellarg( $url ),
				array(
					'return'     => 'all',
					'launch'     => true,
					'exit_error' => false,
				)
			);

			if ( is_object( $result ) && isset( $result->return_code ) && 0 !== $result->return_code ) {
				$failed++;
				WP_CLI::warning( sprintf( 'Cron command failed for %s (exit code %d).', $url, $result->return_code ) );

				if ( ! empty( $result->stderr ) ) {
					WP_CLI::warning( trim( $result->stderr ) );
				}
			} else {
				$success++;
				if ( is_object( $result ) && ! empty( $result->stdout ) ) {
					WP_CLI::log( trim( $result->stdout ) );
				}
			}
		}

		WP_CLI::log( sprintf( 'Finished. Successful sites: %d; failed sites: %d.', $success, $failed ) );

		if ( $failed > 0 ) {
			WP_CLI::error( 'One or more site cron runs failed.' );
		}
	}
);

What each part does:

  • The guard at the top registers the command only when WP-CLI is running and the install is Multisite. On web requests the file returns immediately, so it costs nothing on page loads.
  • get_sites() with 'number' => 0 returns every site with no limit (the default is 100), and the deleted, archived and spam filters skip sites that shouldn’t run anything.
  • WP_CLI::runcommand() with 'launch' => true starts a fresh WP-CLI process for each site, with --url set to that site. 'return' => 'all' captures stdout, stderr and the exit code; 'exit_error' => false stops one failing site from killing the whole run.
  • The summary and WP_CLI::error() at the end print how many sites succeeded and failed, and exit with status 1 if any failed, so cron mail, a log watcher or a monitoring agent can see it.

Why must each site run in its own process?

The first version of this command in my runbook passed 'launch' => false to reuse the current process. It looks faster and it prints the right URLs, but it doesn’t work, and nothing in the output says so.

Before code from my runbooks goes into a post, an AI review step checks every command against the tool’s source and docs, then tests it. Here it read WP_CLI::runcommand() in WP-CLI 2.12: when launch is false, the command string is parsed into arguments, and the global parameters, including --url, are parsed out and then dropped. The command runs inside the WordPress that is already loaded, which is the main site.

The test confirmed it. On a three-site local Multisite network (WordPress 7.1.3, WP-CLI 2.12), one test event was scheduled on each site, with a hook that logged the blog ID it ran in. The in-process version printed this:

Running due cron events for 3 sites.
[1/3] http://ms.test
Executed the cron event 'wp_privacy_delete_old_export_files' in 0.001s.
Executed the cron event 'wp_privacy_personal_data_cleanup_requests' in 0.001s.
Executed the cron event 'mstest_hook' in 0s.
Success: Executed a total of 3 cron events.
[2/3] http://ms.test/one
Success: Executed a total of 0 cron events.
[3/3] http://ms.test/two
Success: Executed a total of 0 cron events.
Finished. Successful sites: 3; failed sites: 0.

Three successes, and the log showed the hook ran only in blog 1. The two subsite events were still queued afterwards. With 'launch' => true, the same run executed the queued events on /one and /two, and the log showed blogs 2 and 3. That is the one-word difference between a working cron runner and one that reports success while skipping every subsite.

A separate process per site also matches how WordPress really behaves: each site can have different active plugins and themes, which register different cron hooks. Switching sites inside one process with switch_to_blog() changes the database tables but not the loaded plugins, so a hook whose plugin is only active on the subsite would have no callback.

Why does the exit code matter?

The runbook version also ended with WP_CLI::error( '...', false ). The second argument tells WP-CLI not to exit, so the command printed “Error: One or more site cron runs failed.” and then exited with status 0. In the test, with an event that threw an exception on one subsite, the summary said failed sites: 1 and echo $? printed 0. Dropping the false makes it exit 1, which I confirmed with the same failing event.

This is the same lesson as in my post on running unattended scheduled checks safely: anything that runs on a schedule is judged by its exit code. A human skimming a log might catch the word “Error”; cron, a monitoring system or an AI agent reading job status will see 0 and move on.

How do I test it before switching the server cron?

From the WordPress installation directory, run it by hand as the same system user the cron job uses:

wp multisite-cron run
echo $?

You should see one [n/total] line per active site, each site’s own events, the summary line, and an exit code of 0. Then check that a subsite’s queue actually moved:

wp site list --field=url
wp cron event list --url=https://example.com/subsite/ --fields=hook,next_run_relative,recurrence

Events that were overdue before the run should now show a future next_run_relative, or be gone if they were one-off events. Test on staging or in a low-traffic window first: the first run on a network whose subsites haven’t run cron in a while can work through a backlog of overdue events on every site.

How do I switch the server cron over?

Replace the existing command:

wp cron event run --due-now

with:

wp multisite-cron run

Keep the same working directory and the same system user as the existing cron job; running WP-CLI as root or as another user creates files the web server can’t write later. A full crontab line looks like this, with placeholder paths:

*/5 * * * * cd /var/www/example.com/htdocs && /usr/bin/flock -n /tmp/example-multisite-cron.lock /usr/local/bin/wp multisite-cron run >> /home/example/logs/multisite-cron.log 2>&1

flock -n (from util-linux, standard on Ubuntu) takes a lock and gives up at once if the previous run still holds it, so two runs never overlap. You can also run it without flock; then make sure the interval is long enough for a full run to finish.

What are the limits?

  • Sites run one after another. Each site gets its own WordPress bootstrap, so runtime grows with network size. Time a manual run and pick a cron interval comfortably above it. For large networks, split sites across several jobs or wait for --network to reach a stable release.
  • A failed event is not retried by this command. WP-CLI removes an event from the queue before it calls the hook (a recurring event is rescheduled first). In the failure test, the one-off event that threw an exception was gone on the next pass, so it never ran again. The non-zero exit tells you to look; the fix is in the plugin that owns the hook.
  • It needs proc_open. Launching a process per site uses PHP’s proc_open in the CLI. Some hosts disable it; WP-CLI then stops with a clear error, and the shell loop is your fallback.

How do I roll it back?

Put the old wp cron event run --due-now line back in the crontab, then delete wp-content/mu-plugins/multisite-cron-runner.php. The plugin stores nothing in the database, so there is nothing else to clean up. If the cron job seems not to run at all, the read-only steps in my WordPress site troubleshooting checklist are where I start. For work that is too slow for cron altogether, such as AI calls, I move it to a queue instead, as in decoupling AI automation in WordPress with async workers.

Quick reference

  • Plugin path: wp-content/mu-plugins/multisite-cron-runner.php
  • Run by hand: wp multisite-cron run, then echo $? (0 = all sites ok, 1 = a site failed)
  • Check a subsite: wp cron event list --url=https://example.com/subsite/
  • Crontab: same directory and user as before, flock -n to stop overlapping runs
  • The two lines that matter: 'launch' => true and WP_CLI::error() without false

FAQ

Does WP-Cron run on every site in a Multisite network?

Each site has its own cron queue. With normal WP-Cron, each site’s events run when that site gets a visit. With DISABLE_WP_CRON and a server cron job, only the sites the job targets with --url run their events.

Can I use wp cron event run –network?

Not on a stable WP-CLI yet. The documentation marks --network as available only in the nightly build (wp cli update --nightly). Until it reaches a stable release, use a per-site loop or the command above.

Why does my subsite’s scheduled post miss its time?

Usually because nothing runs that subsite’s cron. Check wp cron event list --url=<subsite> for overdue events and check whether your server cron job runs once per site.

Does this work with subdomain and subdirectory networks?

Yes. The command uses each site’s own URL from get_site_url(), and WP-CLI resolves the site from --url in either layout.

Filed under
Aditya Shah Avatar

Written by

Hosting Support Manager & DevOps Engineer at WPMU DEV. I scale and secure WordPress infrastructure, and organize WordPress Bhopal and GDG Cloud Bhopal.

More about me · LinkedIn · GitHub

Need help with WordPress hosting, a migration or a production incident? Work with me.

New articles, by email

No spam, just new posts as they’re published.

Your email address goes to WordPress.com (Automattic), which sends you new posts. Unsubscribe from any email. Privacy policy.