Multisite

How a network differs from a site, what switch_to_blog actually swaps, and the cases where multisite is the wrong tool.

Syntaxswitch_to_blog( int $new_blog_id ): true — always paired with restore_current_blog(): bool

Multisite turns one install into a network of sites sharing core, the plugin and theme directories, and the users table. Each site gets its own numbered tables — wp_2_posts, wp_2_options, wp_2_postmeta — while wp_users, wp_usermeta, wp_blogs, wp_site and wp_sitemeta are network-wide. In code, $wpdb->prefix is the current site's prefix and $wpdb->base_prefix is the network's.

Network versus site

A network-activated plugin loads on every site and cannot be deactivated site by site. Test for it with is_plugin_active_for_network(), which lives in wp-admin/includes/plugin.php and must be required before you call it outside the admin. Network-wide settings live in wp_sitemeta and are read with get_site_option(); per-site settings stay in that site's options table under get_option(). Super admins hold capabilities everywhere; ordinary roles are granted per site.

Switching sites

switch_to_blog( $id ) repoints $wpdb and the cache key prefixes at another site, and restore_current_blog() pops one entry off the switched stack. They pair one to one: restore_current_blog() is not "return to the root site", and an unbalanced switch leaves later queries and URLs pointing at the wrong place.

When it is the wrong answer

Multisite fits sites that share users and a plugin set and are run by one team. It fits badly when sites need different plugins, different release schedules or different owners: one core upgrade, one bad plugin update and one outage hit every site at once, and there is no per-site rollback.

Example

Example · php
<?php
/**
 * Collect the newest post from every public site on the network.
 */
function acme_network_latest(): array {
    if ( ! is_multisite() ) {
        return array();
    }

    // get_sites( string|array $args = array() ): WP_Site[]|int[]|int  (4.6)
    // 'fields' => 'ids' is the shorthand for calling get_sites() and then
    // wp_list_pluck( $sites, 'blog_id' ) — same IDs, no WP_Site objects built.
    $site_ids = get_sites( array(
        'fields'   => 'ids',
        'number'   => 0, // 0 means no limit; the default caps results at 100
        'public'   => 1,
        'archived' => 0,
        'deleted'  => 0,
    ) );

    $out = array();

    foreach ( $site_ids as $site_id ) {
        switch_to_blog( $site_id ); // repoints $wpdb and the cache prefixes

        $latest = get_posts( array( 'numberposts' => 1 ) );
        if ( $latest ) {
            $out[ $site_id ] = array(
                'title' => get_the_title( $latest[0] ),
                // Only correct while switched — call it after restoring and you
                // get a URL on the wrong domain.
                'url'   => get_permalink( $latest[0] ),
            );
        }

        // One restore per switch. The stack is LIFO, not a reset to the root site.
        restore_current_blog();
    }

    return $out;
}

When to use it

  • A university runs forty departmental sites with one theme and one editorial team.
  • A network plugin has to create its custom table on every existing and future site.
  • You need a report of the newest post across the whole network on one dashboard.
  • A client asks for multisite and you need to decide whether separate installs are safer.

More examples

Network settings versus site settings

Deciding which storage a setting belongs in is the main design question in a network plugin: a licence key is network-wide, a colour is per site. The get_site_option() family degrading to get_option() on single-site installs is what lets one plugin support both without branching.

Example · php
<?php
// Per-site: stored in that site's wp_{id}_options table.
update_option( 'acme_theme_accent', '#0b5' );
$accent = get_option( 'acme_theme_accent', '#000' );

// Network-wide: stored in wp_sitemeta, one value for every site.
// On a single-site install these fall through to get_option()/update_option(),
// so the same code works in both places.
update_site_option( 'acme_licence_key', $key );
$licence = get_site_option( 'acme_licence_key', '' );

// Is this plugin network-activated? The function lives in an admin-only file.
function acme_is_network_plugin(): bool {
    if ( ! is_multisite() ) {
        return false;
    }
    if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
        require_once ABSPATH . 'wp-admin/includes/plugin.php';
    }
    // is_plugin_active_for_network( string $plugin ): bool  (3.0)
    return is_plugin_active_for_network( plugin_basename( __FILE__ ) );
}

Activation across a network

This is the bug report every network plugin gets: it works on the sites that existed at activation and silently has no tables on sites created afterwards. The $network_wide argument passed to the activation hook is the only signal that a loop is needed.

Example · php
<?php
/**
 * register_activation_hook() fires ONCE for a network activation, not once per
 * site, so a plugin that creates a table or seeds options must loop itself.
 */
register_activation_hook( __FILE__, function ( bool $network_wide ): void {
    if ( is_multisite() && $network_wide ) {
        foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) {
            switch_to_blog( $site_id );
            acme_install_tables();
            restore_current_blog();
        }
        return;
    }

    acme_install_tables();
} );

// New sites created after activation get nothing from the hook above, so
// handle them as they appear. The wp_initialize_site action is fired by
// wp_insert_site(); core's own wp_initialize_site() callback is hooked to it at
// priority 10, and that callback switch_to_blog()s, builds the tables, then
// calls restore_current_blog() before returning. So at priority 20 the tables
// exist but the new site is NOT the current one — you must switch yourself.
add_action( 'wp_initialize_site', function ( WP_Site $site ): void {
    switch_to_blog( $site->blog_id );
    acme_install_tables();
    restore_current_blog();
}, 20 );

When to say no to multisite

Useful for reporting and migration scripts, and it makes the underlying model concrete: a network is one database with prefixed table sets. It is also the argument against multisite — if the only thing you need is shared content, the REST API between two independent installs gives you separate upgrade schedules and separate outages.

Example · php
<?php
// A single query on the main site can read another site's data — you do not
// need multisite to share content between installs, and you do not need
// switch_to_blog() for a one-off read either.
global $wpdb;

// $wpdb->base_prefix is the network prefix ('wp_'), $wpdb->prefix is the
// current site's ('wp_2_'). get_blog_prefix() builds one for any site id.
$prefix = $wpdb->get_blog_prefix( 2 );

$titles = $wpdb->get_col(
    "SELECT post_title FROM {$prefix}posts
      WHERE post_status = 'publish' AND post_type = 'post'
      ORDER BY post_date DESC LIMIT 5"
);
// No hooks, no filters, no caching. Fine for a report; wrong for anything
// that must respect plugin behaviour — use switch_to_blog() for that.

Discussion

  • Be the first to comment on this lesson.