Multisite Development

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

Plugin code on a Multisite network runs for one site per request, yet may need to read other sites, keep network-wide settings and react when sites are created or deleted. This page covers the functions and hooks for that, what they cost, and the mistakes that appear once a second site exists. Setting up the network itself is in Multisite Networks.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and planned for 8 December 2026; it announces no change to these APIs so far. Every output below comes from a 7.1.3 site (official wordpress:7.1.3-php8.4-apache and wordpress:cli-2.12.0-php8.4 images, MariaDB 11.8, table prefix wp_) installed as a single site, checked, and then converted to a subdirectory network at lumenpress.test with two imprint sites, Lumen Kids (/kids/, site 2) and Lumen Noir (/noir/, site 3), created with wp site create. lumen-catalog is network-activated.

The plugin skeleton and its per-site activation loop are in Plugin Fundamentals, and the role on new sites in Roles and Capabilities API.

Detecting the network and the current site

The database vocabulary is inverted: a site (wp_site, get_current_network_id()) is a network and a blog (wp_blogs, blog_id) is a site. Newer functions use the admin screens' words (get_sites(), get_site_meta(), get_network_option()); older ones keep "blog" and "site" in the database sense (switch_to_blog(), get_site_option(), which reads a network option).

Function or property Returns

is_multisite()

true when the MULTISITE constant is true. Check it before calling any network-only function.

get_current_blog_id()

The ID of the site whose tables are in use, which changes with switch_to_blog(). 1 on a single site.

get_current_network_id()

The network ID (wp_site.id), 1 unless several networks share the tables. 1 on a single site.

get_main_site_id( $network_id ) (4.9), is_main_site( $site_id )

The network’s main site; avoid assuming it is 1.

get_site( $site ) (4.6), get_network( $network ) (4.6)

A WP_Site or WP_Network object, from the object cache when possible.

$wpdb->prefix, $wpdb->base_prefix, $wpdb->get_blog_prefix( $id )

The current site’s table prefix (wp_2_ for Lumen Kids), the network prefix (wp_, for wp_users, wp_blogs, wp_sitemeta), and the prefix of any site without switching.

wp-settings.php loads ms-blogs.php (which includes ms-site.php), ms-functions.php and WP_Site_Query only on Multisite. Before the conversion the test site reported function_exists() false for get_sites, switch_to_blog and get_site_meta, while the network option functions and is_super_admin() existed. lumen-catalog therefore calls Network::boot() from Plugin::boot() only if ( is_multisite() ).

Switching sites

switch_to_blog( $site_id ) makes another site of the network current for the rest of the code until restore_current_blog() is called. It does less than its name suggests.

Changes Does not change

$wpdb->prefix, $wpdb->blogid and the $table_prefix and $blog_id globals, so every option, post, term, comment and per-site meta call reads the target site’s tables

The plugins and must-use plugins loaded for the request, and the post types, taxonomies, blocks, shortcodes, hooks and REST routes they registered

The object cache’s per-site key prefix (wp_cache_switch_to_blog())

The active theme’s PHP code (functions.php, template files); get_option( 'stylesheet' ) returns the target’s theme name, but its code is not loaded

wp_roles() and the current user’s capabilities, reloaded for the target site by wp_switch_roles_and_user() (4.9), hooked to switch_blog at priority 1

$wp_rewrite (the permalink structure and permastructs), $wp_query, the global $post, and constants such as WP_CONTENT_URL

The previous IDs are pushed on the $GLOBALS['_wp_switched_stack'] array. restore_current_blog() pops one entry and returns false when the stack is empty; ms_is_switched() (3.5) is true while the stack is not empty. Every switch fires the switch_blog action with $new_blog_id, $prev_blog_id and, since 5.4, a $context of switch or restore, even when the target is already the current site. A short script, run with wp eval-file:

<?php
/**
 * What switch_to_blog() changes, and what it does not. Run with: wp eval-file switch-demo.php
 */

global $wpdb;

$report = static function ( string $label ) use ( $wpdb ): void {
	printf( "%-9s blog=%d prefix=%s base_prefix=%s switched=%s\n", $label, get_current_blog_id(), $wpdb->prefix, $wpdb->base_prefix, var_export( ms_is_switched(), true ) );
};

$report( 'start' );
switch_to_blog( 2 ); // Lumen Kids.
$report( 'kids' );
echo 'home_url():     ', home_url( '/' ), "\n";
echo 'get_permalink:  ', get_permalink( get_page_by_path( 'the-paper-kite', OBJECT, 'lp_book' ) ), "\n";
switch_to_blog( 3 ); // Lumen Noir, nested.
echo 'stack:          ', implode( ',', $GLOBALS['_wp_switched_stack'] ), "\n";
restore_current_blog();
restore_current_blog();
$report( 'restored' );
echo 'extra restore:  ', var_export( restore_current_blog(), true ), "\n";
$ wp eval-file switch-demo.php
start     blog=1 prefix=wp_ base_prefix=wp_ switched=false
kids      blog=2 prefix=wp_2_ base_prefix=wp_ switched=true
home_url():     http://lumenpress.test/kids/
get_permalink:  http://lumenpress.test/kids/blog/books/the-paper-kite/
stack:          1,2
restored  blog=1 prefix=wp_ base_prefix=wp_ switched=false
extra restore:  false
$ wp post list --post_type=lp_book --name=the-paper-kite --url=lumenpress.test/kids/ --fields=ID,url
ID	url
3	http://lumenpress.test/kids/books/the-paper-kite/

The permalink built while switched is wrong. The main site of a subdirectory network has the forced /blog/ front in its permalink structure, and the lp_book permastruct was registered with that front in the main site’s request, so get_permalink() added /blog/ to a Lumen Kids URL, which answered only with a 301 redirect to the right address. Build URLs of another site’s custom content in that site’s own context (its REST API, or a URL stored on save).

Capabilities do switch: current_user_can() answers for the target site. With maria.editor (an Editor of the main site only) as current user, a wp eval loop printed site 1 roles=[editor] edit_posts=true and site 3 roles=[] edit_posts=false. current_user_can_for_site( $site_id, $capability ) (6.7, replacing current_user_can_for_blog()) does the switch and restore itself.

The object cache while switched

The default WP_Object_Cache keeps one array per request and prefixes the keys of non-global groups with the site ID, so switching is cheap and the cache stays correct. Global groups (users, userlogins, site-options, site-transient, networks, sites, blog-details and others declared with wp_cache_add_global_groups()) are shared by every site, which is why a plugin that caches network-wide data adds its group there (see Options, Transients and Object Cache).

A persistent drop-in should implement switch_to_blog() on its cache object. Since 7.0, for a drop-in that lacks it, wp_cache_switch_to_blog() (in cache-compat.php) calls wp_cache_switch_to_blog_fallback(), which reinitialises the cache and re-adds the global and non-persistent groups: correct, but it discards the in-memory cache on every switch. The Redis Object Cache drop-in of Performance and Caching has the method.

What a switch costs

On the test network with the default cache, 1,000 switch_to_blog()/restore_current_blog() pairs between the two imprints took about 20 µs per pair, and the same loop with get_option( 'blogname' ) and wp_count_posts( 'lp_book' ) inside ran two queries in total. The switch is cheap; the first visit to each site is not, because it loads that site’s autoloaded options and counts: about two queries per site (figures in A network-wide loop: books per imprint). On a large network, page through sites, cache the result network-wide, run the work from WP-Cron or WP-CLI rather than on a page view, or query the tables directly.

Querying sites with get_sites() and WP_Site_Query

get_sites( $args ) (4.6) wraps WP_Site_Query and returns WP_Site objects, IDs or a count. It replaces wp_get_sites(), deprecated in 4.6. The results are cached in the global site-queries group and invalidated when a site changes.

Argument Effect

number

Maximum sites returned. Defaults to 100; 0 removes the limit. Page with offset or paged.

fields

ids for an array of integers, count (or 'count' => true) for a number; empty for WP_Site objects.

network_id, domain, path, path__not_in, search, lang_id

Filter by network, address, search string or language.

public, archived, mature, spam, deleted

Status flags (1 or 0). Nothing is excluded by default, so a public loop passes 'archived' => 0, 'spam' => 0, 'deleted' => 0.

meta_key, meta_value, meta_query (5.1)

Site meta conditions, joined on wp_blogmeta.

update_site_cache, update_site_meta_cache (5.1)

Prime the site and site meta caches in one query each (both default true).

orderby, order, no_found_rows

Order by id, domain, path, registered, last_updated and others; no_found_rows defaults to true, so found_sites and max_num_pages stay 0 unless it is set to false.

A WP_Site (4.5) mirrors a wp_blogs row, and its columns are strings: blog_id is "3", public is "1". The magic property id returns an integer, and blogname, siteurl, post_count and home are loaded on demand from the site’s options (a switch behind the scenes, then cached in site-details). Compare with === only after casting:

$ wp eval '$s = get_site( 3 ); var_dump( $s->blog_id, $s->id, $s->path, $s->public, $s->blogname );'
string(1) "3"
int(3)
string(6) "/noir/"
string(1) "1"
string(10) "Lumen Noir"
$ wp eval 'echo implode( ",", get_sites( array( "fields" => "ids", "path" => "/kids/" ) ) ), "\n"; $q = new WP_Site_Query( array( "number" => 2, "fields" => "ids", "no_found_rows" => false ) ); echo implode( ",", $q->sites ), " found_sites=", $q->found_sites, " max_num_pages=", $q->max_num_pages, "\n";'
2
1,2 found_sites=3 max_num_pages=2

Network options

Network options live in wp_sitemeta, one row per network (site_id) and key, and hold settings that belong to the network rather than to one site: core’s site_admins, active_sitewide_plugins and upload limits, and plugin settings set by a super admin.

Function Notes

get_network_option( $network_id, $option, $default ), add_network_option(), update_network_option(), delete_network_option() (4.4)

Any network; null means the current one.

get_site_option( $option, $default ), add_site_option(), update_site_option(), delete_site_option()

Wrappers for the current network; the names predate the site/network renaming.

get_site_transient(), set_site_transient(), delete_site_transient()

Network-wide transients, stored as _site_transient_* rows in wp_sitemeta without a persistent cache.

pre_site_option_{$option}, pre_site_option (6.9), site_option_{$option}, default_site_option_{$option}, update_site_option_{$option}

Short-circuit, filter, default and change hooks.

Each value is cached in the global site-options group under "$network_id:$option", and missing keys in a notoptions entry, so a missing option costs one query per request, not one per call. There is no autoload for network options: only core’s own keys are preloaded (wp_load_core_site_options()), and each plugin key costs one query per request without a persistent object cache. Group a plugin’s network settings into one array option, as lumen-catalog does with lp_catalog_network_settings.

On a single site the same functions read and write wp_options. Before the conversion, update_site_option( 'lp_catalog_network_settings', ... ) created a wp_options row with autoload off, so code written with get_site_option() works unchanged on both kinds of installation. Network options need no register_setting(): the Settings API and options.php work per site only, which is why network screens save through network/edit.php (see A network settings screen). On the network, wp site option get lp_catalog_network_settings --format=json reads the value.

Site meta

Site meta (5.1) attaches data to a site from outside it, in the network table wp_blogmeta, without switching. It is the place for facts about a site that network code queries: an imprint code, a plan, a billing ID. Options of the site itself remain the place for that site’s own settings.

Function Notes

add_site_meta(), get_site_meta( $site_id, $key, $single ), update_site_meta(), delete_site_meta(), delete_site_meta_by_key()

Wrappers of the metadata API for the object type blog; cached in the global blog_meta group.

is_site_meta_supported()

true once wp_blogmeta exists; the answer is stored in the site_meta_supported network option. A network not yet upgraded to 5.1 returns false, and the functions above then fail with _doing_it_wrong.

get_sites( array( 'meta_key' => ..., 'meta_query' => ... ) )

Finds sites by meta, as WP_Query does for posts.

Lumen Press marks its imprint sites with lp_imprint, so network code finds them without hard-coding IDs:

$ wp site meta update 2 lp_imprint kids
Success: Updated custom field 'lp_imprint'.
$ wp site meta update 3 lp_imprint noir
Success: Updated custom field 'lp_imprint'.
$ wp site meta list 3
blog_id	meta_key	meta_value
3	lp_imprint	noir
$ wp eval 'echo implode( ",", get_sites( array( "fields" => "ids", "meta_key" => "lp_imprint" ) ) ), "\n";'
2,3

The Network class of lumen-catalog

All network behaviour of lumen-catalog sits in one class, booted from Plugin::boot() only on Multisite. It creates the plugin’s data on new sites, drops its table with a deleted site, counts the published books of every imprint, and adds a network settings screen.

src/Network.php

<?php
/**
 * Network-level features: site life cycle, network settings and network-wide statistics.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Booted from Plugin::boot() only when is_multisite() is true.
 */
final class Network {

	const SETTINGS = 'lp_catalog_network_settings'; // Network option (wp_sitemeta).
	const COUNTS   = 'lp_network_book_counts';      // Site transient, shared by the network.
	const IMPRINT  = 'lp_imprint';                  // Site meta key (wp_blogmeta).
	const PAGE     = 'lumen-catalog-network';       // Admin page slug and network/edit.php action.

	public static function boot(): void {
		add_action( 'wp_initialize_site', array( self::class, 'initialize_site' ), 900 );
		add_filter( 'wpmu_drop_tables', array( self::class, 'drop_tables' ), 10, 2 );
		add_action( 'wp_delete_site', array( self::class, 'flush_counts' ) );
		add_action( 'save_post_lp_book', array( self::class, 'flush_counts' ) );
		add_action( 'deleted_post', array( self::class, 'flush_counts' ), 10, 2 );
		add_action( 'network_admin_menu', array( self::class, 'add_menu' ) );
		add_action( 'network_admin_edit_' . self::PAGE, array( self::class, 'save_settings' ) );
	}

	public static function is_network_active(): bool {
		if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php'; // Admin-only file.
		}
		return is_plugin_active_for_network( plugin_basename( LUMEN_CATALOG_FILE ) );
	}

	// A new site gets its table and settings now; priority 900 runs after core (10) created its tables.
	public static function initialize_site( \WP_Site $site ): void {
		if ( ! self::is_network_active() ) {
			return;
		}
		$network = (array) get_site_option( self::SETTINGS, array() );

		switch_to_blog( $site->id );
		Installer::upgrade();
		delete_option( 'rewrite_rules' ); // Built while switched, with the main site's /blog/ front: rebuild on first request.
		if ( isset( $network['isbn_prefix'] ) ) {
			update_option( 'lp_catalog_settings', array( 'isbn_prefix' => $network['isbn_prefix'] ) );
		}
		restore_current_blog();
	}

	// Core drops only its own tables (DROP TABLE IF EXISTS); add every per-site table of the plugin.
	public static function drop_tables( array $tables, int $site_id ): array {
		global $wpdb;
		$prefix   = $wpdb->get_blog_prefix( $site_id );
		$tables[] = $prefix . 'lp_stock';
		$tables[] = $prefix . 'lp_loans'; // Schema version 3, added in REST API: Extending It.
		return $tables;
	}

	// Published books per imprint site, cached for the whole network for one hour.
	public static function book_counts(): array {
		$counts = get_site_transient( self::COUNTS );
		if ( is_array( $counts ) ) {
			return $counts;
		}

		$counts   = array();
		$site_ids = get_sites(
			array(
				'fields'   => 'ids',
				'meta_key' => self::IMPRINT, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- wp_blogmeta is small.
				'archived' => 0,
				'spam'     => 0,
				'deleted'  => 0,
				'number'   => 500,
			)
		);
		foreach ( $site_ids as $site_id ) {
			switch_to_blog( $site_id );
			$by_status          = wp_count_posts( 'lp_book' );
			$counts[ $site_id ] = array(
				'imprint' => (string) get_site_meta( $site_id, self::IMPRINT, true ),
				'name'    => (string) get_option( 'blogname' ),
				'books'   => (int) ( $by_status->publish ?? 0 ),
			);
			restore_current_blog();
		}

		set_site_transient( self::COUNTS, $counts, HOUR_IN_SECONDS );
		return $counts;
	}

	// Drops the cached counts when a book or a site changes ($post is set on deleted_post).
	public static function flush_counts( $item = null, $post = null ): void {
		if ( $post instanceof \WP_Post && 'lp_book' !== $post->post_type ) {
			return;
		}
		delete_site_transient( self::COUNTS );
	}

	public static function add_menu(): void {
		add_submenu_page( 'settings.php', __( 'Lumen Catalog network settings', 'lumen-catalog' ), __( 'Lumen Catalog', 'lumen-catalog' ), 'manage_network_options', self::PAGE, array( self::class, 'render_page' ) );
	}

	public static function render_page(): void {
		$settings = (array) get_site_option( self::SETTINGS, array( 'isbn_prefix' => '978' ) );
		?>
		<div class="wrap">
			<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
			<?php if ( isset( $_GET['updated'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
				<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Settings saved.', 'lumen-catalog' ); ?></p></div>
			<?php endif; ?>
			<form method="post" action="<?php echo esc_url( network_admin_url( 'edit.php?action=' . self::PAGE ) ); ?>">
				<?php wp_nonce_field( self::PAGE ); ?>
				<p><label for="lp-isbn-prefix"><?php esc_html_e( 'ISBN prefix for new imprints', 'lumen-catalog' ); ?></label>
				<input name="isbn_prefix" id="lp-isbn-prefix" type="text" class="small-text" value="<?php echo esc_attr( $settings['isbn_prefix'] ?? '978' ); ?>"></p>
				<?php submit_button(); ?>
			</form>
			<h2><?php esc_html_e( 'Published books per imprint', 'lumen-catalog' ); ?></h2>
			<table class="widefat striped">
				<?php foreach ( self::book_counts() as $row ) : ?>
					<tr><td><?php echo esc_html( $row['name'] ); ?></td><td><?php echo esc_html( number_format_i18n( $row['books'] ) ); ?></td></tr>
				<?php endforeach; ?>
			</table>
		</div>
		<?php
	}

	// Saves the form. network/edit.php checks only that the user is logged in.
	public static function save_settings(): void {
		if ( ! current_user_can( 'manage_network_options' ) ) {
			wp_die( esc_html__( 'Sorry, you are not allowed to manage options for this network.', 'lumen-catalog' ), 403 );
		}
		check_admin_referer( self::PAGE );

		$prefix = isset( $_POST['isbn_prefix'] ) ? sanitize_text_field( wp_unslash( $_POST['isbn_prefix'] ) ) : '978';
		update_site_option( self::SETTINGS, array( 'isbn_prefix' => in_array( $prefix, array( '978', '979' ), true ) ? $prefix : '978' ) );

		wp_safe_redirect( add_query_arg( array( 'page' => self::PAGE, 'updated' => 'true' ), network_admin_url( 'settings.php' ) ) );
		exit;
	}
}

A network-wide loop: books per imprint

book_counts() finds the imprint sites by site meta, excludes archived, spam and deleted sites, switches once per site and stores the result in a site transient, so the work runs once an hour for the whole network rather than on every request of every site. Three books were published on the main site, two (later three) on Lumen Kids, and three plus a draft on Lumen Noir:

$ wp eval 'foreach ( Lumen\Catalog\Network::book_counts() as $id => $row ) { printf( "%d  %-5s %-11s %d\n", $id, $row["imprint"], $row["name"], $row["books"] ); }'
2  kids  Lumen Kids  2
3  noir  Lumen Noir  3
$ wp transient get lp_network_book_counts --network --format=json
{"2":{"imprint":"kids","name":"Lumen Kids","books":2},"3":{"imprint":"noir","name":"Lumen Noir","books":3}}
$ wp post create --post_type=lp_book --post_status=publish --post_title="Owl Post" --url=lumenpress.test/kids/ --porcelain
5
$ wp transient get lp_network_book_counts --network
Warning: Transient with key "lp_network_book_counts" is not set.

The main site is not counted (it has no lp_imprint meta) and the draft is not published. Saving a book on Lumen Kids fired save_post_lp_book there, and flush_counts() deleted the network transient: site transients are network-wide from whichever site writes them. wp_count_posts() returns an empty object when the post type is not registered in the current request, hence the ?? 0; a registered type stays registered after a switch even on a site where the plugin is not active, so the count for such a site would still be read from its tables.

For hundreds of sites, read the tables directly instead of switching: $wpdb->get_blog_prefix( $id ) gives each site’s prefix, so one UNION ALL of prepared per-site SELECT %d AS site_id, COUNT(*) FROM {prefix}posts WHERE post_type = %s AND post_status = %s parts counts every imprint in one query (2 queries and 1.4 ms for 42 sites, against 89 queries and 13 ms for the loop). Direct SQL skips every hook and cache that WP_Query and wp_count_posts() apply and depends on the schema, so keep it for read-only statistics and keep writes on the APIs.

Site life cycle hooks

Since 5.1 a site’s life cycle has object-based hooks, and the old ones are deprecated:

Hook When

wp_insert_site ( WP_Site )

After the wp_blogs row is inserted, before any table exists. Do not switch here.

wp_initialize_site ( WP_Site, $args )

Core’s wp_initialize_site() at priority 10 creates the tables, default options, roles and content; $args holds user_id, title, options and meta. Plugins hook later (lumen-catalog uses 900). Replaces wpmu_new_blog.

wp_uninitialize_site ( WP_Site )

Core’s callback at priority 10 drops the tables returned by wpmu_drop_tables and deletes the site’s uploads. Data still readable: hook before 10.

wpmu_drop_tables ( $tables, $site_id )

The list of tables to drop; it contains only core’s per-site tables.

wp_delete_site ( WP_Site )

After the row and its wp_blogmeta rows are gone. Replaces delete_blog.

wp_update_site ( new WP_Site, old WP_Site )

After a change of address or status flag.

With the network setting at 979, a new site was ready at once: the table existed (checked from the main site, before any request to the new one) and its settings carried the network default instead of the 978 that the upgrade routine writes. Deleting it dropped wp_4_lp_stock with the core tables; without the wpmu_drop_tables filter, a deleted site 5 left its wp_5_lp_stock behind. The filter also lists lp_loans, the second per-site table that schema version 3 adds in REST API: Extending It; core drops tables with DROP TABLE IF EXISTS, so the name is harmless on a site still at version 2, as here.

$ wp site create --slug=classics --title="Lumen Classics" --email=admin@lumenpress.test --porcelain
4
$ wp db query "SHOW TABLES LIKE 'wp\_4\_lp%'" --skip-column-names
wp_4_lp_stock
$ wp site delete 4 --yes
Success: The site at 'http://lumenpress.test/classics/' was deleted.
$ wp db query "SHOW TABLES LIKE 'wp\_4\_%'" --skip-column-names | wc -l
0

The delete_option( 'rewrite_rules' ) line fixes a problem found on the test network. Core’s site set-up rebuilds the new site’s rewrite rules while switched from the main site, using the permastructs registered in that request, which carry the main site’s /blog/ front: on Lumen Kids /kids/books/the-paper-kite/ returned 404 and the stored rules started with blog/books/. Deleting the option makes WordPress rebuild the rules on the site’s first request, with its own structure; with it, a new site’s /classics/books/old-harbour-tales/ returned 200. Never call flush_rewrite_rules() inside switch_to_blog() for the same reason. Sites created earlier are repaired with wp rewrite flush --url=lumenpress.test/kids/. The Catalogue Manager role on new sites follows the same pattern in Roles and Capabilities API.

A network settings screen

Network Admin has its own menu hook, network_admin_menu; admin_menu does not fire there (see Administration Menus and Settings API). Parent slugs are the network screens: settings.php, sites.php, users.php, themes.php, plugins.php, index.php. is_network_admin() tells code it is running there, and network_admin_url() builds its links.

Because the Settings API cannot save network options, the form posts to wp-admin/network/edit.php, which fires network_admin_edit_{$action} for the action query argument and then redirects to the Network Admin dashboard. That file checks only that the user is logged in, so the handler itself must check the capability and the nonce. With cookies from wp-login.php for admin (super admin) and maria.editor (an Editor of the main site):

GET  /wp-admin/network/settings.php?page=lumen-catalog-network (admin)
     200, <h1>Lumen Catalog network settings</h1>, rows "Lumen Kids 3" and "Lumen Noir 3"
POST /wp-admin/network/edit.php?action=lumen-catalog-network (admin, with _wpnonce, isbn_prefix=978)
     302 to /wp-admin/network/settings.php?page=lumen-catalog-network&updated=true
POST /wp-admin/network/edit.php?action=lumen-catalog-network (admin, no nonce)
     403, "The link you followed has expired."
POST /wp-admin/network/edit.php?action=lumen-catalog-network (maria.editor)
     403, "Sorry, you are not allowed to manage options for this network."
GET  /wp-admin/network/settings.php?page=lumen-catalog-network (maria.editor)
     403

Without the current_user_can() line, any logged-in user able to obtain a nonce for the action could change a network setting. Admin notices on network screens use network_admin_notices, and plugin row links on Network Admin → Plugins use network_admin_plugin_action_links.

Capabilities and network activation

Network screens check the network capabilities, which only super admins have: manage_network, manage_sites, manage_network_users, manage_network_plugins, manage_network_themes and manage_network_options (the right one for a network settings screen). Super admins, the site_admins network option and the capabilities a site Administrator loses on a network are in Multisite Networks and Roles and Capabilities API. Check capabilities, never is_super_admin(), which on a single site is true for anyone with delete_users.

A network-activated plugin’s basename is a key of the active_sitewide_plugins network option (on the test network, {"lumen-catalog/lumen-catalog.php":1793214653}, the value being the activation time); per-site activation stays in each site’s active_plugins option. is_plugin_active_for_network( $basename ) reads that network option. It is declared in wp-admin/includes/plugin.php: on a 7.1.3 front-end request it was already defined at plugins_loaded but not at muplugins_loaded, so code keeps the require_once guard of is_network_active(). A Network: true header forces network-only activation. Network activation runs the activation callback once, on the main site, with $network_wide set to true; how lumen-catalog sets up the other sites is in Plugin Fundamentals.

User names on a network

New user names on a network may contain only lowercase letters and digits ([a-z0-9], at least four characters), a rule applied by wpmu_validate_user_signup(), which Network Admin, wp-signup.php and WP-CLI call. Lower-level functions do not apply it: on the test network wp_insert_user() created nora.noir and wpmu_create_user() created nora.x, both of which the screens would refuse. Plugins that create users on a network validate first:

$ wp user create nora.noir nora@lumenpress.test --role=editor --url=lumenpress.test/noir/
Error: Usernames can only contain lowercase letters (a-z) and numbers.
$ wp user create noranoir nora@lumenpress.test --role=editor --url=lumenpress.test/noir/ --porcelain
7

Common errors and pitfalls

Symptom Cause and fix

Call to undefined function get_sites() (or switch_to_blog()) on some installations

Network-only functions on a single site. Load network code behind is_multisite().

Later code reads the wrong site’s data

A switch_to_blog() without its restore_current_blog(), often on an early return or continue. Restore once per switch; ms_is_switched() shows a leak.

Wrong permalinks, or another site’s custom post type missing, after a switch

Rewrite rules, post types and plugin code are those of the original request. Build URLs in the target site’s context.

404 for custom post types on new sites

Rewrite rules rebuilt while switched. Delete rewrite_rules in wp_initialize_site; wp rewrite flush --url=... for existing sites.

wp_2_lp_stock and similar tables left after deleting sites

Plugin tables are not dropped by core. Add them with wpmu_drop_tables.

Any logged-in user can change a network setting

The network_admin_edit_{$action} handler lacks current_user_can() and check_admin_referer().

Where older books are outdated

Older claim Current behaviour (WordPress 7.1)

Use wpmu_new_blog and delete_blog to react to new and deleted sites

Deprecated in 5.1; use wp_initialize_site (after priority 10) and wp_uninitialize_site or wp_delete_site

List sites with wp_get_sites() or a raw query on wp_blogs

get_sites() and WP_Site_Query (4.6), with meta queries on wp_blogmeta (5.1)

Store per-site facts for network code in each site’s options and switch to read them

Site meta (5.1) is read without switching and is queryable

switch_to_blog() loads the other site’s plugins and theme

Only tables, the object cache prefix, roles and the current user switch

current_user_can_for_blog()

Deprecated in 6.7 in favour of current_user_can_for_site()