Multisite-compatible plugins

Make the events plugin work when network-activated across hundreds of sites, with per-site tables, network settings, clean site deletion and tests that run on Multisite.

  • Intermediate
  • 11 min read
  • Updated 17 Oct 2026

What you will learn

  • Support network activation
  • Store per-site data
  • Add a network settings screen that saves securely
  • Clean up when a site is deleted and test on Multisite

Before you start

  • WordPress Multisite
  • How a Multisite network is organised: sites, network options and switch_to_blog()
  • The migrator from the previous lesson

The WordPress course covered how Multisite works: one installation, many sites, separate tables per site, site options and network options, and switch_to_blog(). This lesson applies that to a real plugin. The events plugin has a custom table, settings, migrations and background jobs, and each of those behaves differently when the plugin is network-activated across hundreds of sites.

Decide what is per site and what is per network

Before writing code, classify every piece of data:

DataScopeStorage
Events, event metaPer siteEach site's posts tables, automatically
BookingsPer site{$wpdb->prefix}myplugin_bookings, one table per site
myplugin_settingsPer siteget_option()
myplugin_db_versionPer siteget_option(), because each site's table has its own schema version
Licence key, network defaultsPer networkget_site_option() / update_site_option()

The two prefixes do the work for tables:

  • $wpdb->prefix is the current site's prefix: wp_ on the main site, wp_3_ on site 3. Use it for per-site tables.
  • $wpdb->base_prefix is the network's base prefix, wp_ on every site. Use it for a table shared by the whole network, such as a network-wide log.

Most plugin data belongs to one site. Choose a shared table only when the data truly spans sites, because it complicates privacy requests, site deletion and moving a site out of the network.

The repository already follows the current site

In lesson 16, Repository::table() reads $this->wpdb->prefix on every call instead of storing the table name in the constructor. That detail matters now. switch_to_blog() changes $wpdb->prefix, so a repository created once still targets the right table after switching:

PHP
<?php

switch_to_blog( 3 );
$bookings = myplugin()->bookings()->for_user( $user_id ); // Reads wp_3_myplugin_bookings.
restore_current_blog();

Storing $wpdb->prefix . 'myplugin_bookings' in a property at construction time is a classic Multisite bug: everything works on the main site and silently reads the wrong table elsewhere.

Network activation without loops

When a super admin network-activates the plugin, the activation hook runs once, on the main site. A loop that creates the table on every site can time out on a large network, leaving some sites half set up. The migrator from lesson 20 removes the need for the loop:

  • each site has its own myplugin_db_version, which starts empty;
  • Plugin::register() runs the migrator on every request;
  • so the first request to each site creates that site's table and records its version.

Setup happens lazily, site by site, under the same lock as any other migration. The activation hook only needs to handle the case where activation is not network-wide:

PHP
<?php
// myplugin/src/Installer.php

namespace MyPlugin;

use MyPlugin\Database\Migrator;

defined( 'ABSPATH' ) || exit;

final class Installer {

    public static function activate( bool $network_wide = false ): void {
        if ( is_multisite() && $network_wide ) {
            // Sites set themselves up on their next request.
            add_site_option( 'myplugin_network_activated_at', time() );
            return;
        }

        self::install();
    }

    public static function install(): void {
        global $wpdb;

        ( new Migrator( $wpdb ) )->maybe_migrate();
    }
}

Background jobs need the same care. A batch queued by site 3's migrator runs in site 3's cron or Action Scheduler context, so $wpdb->prefix is already correct when the callback runs. Do not queue one job that loops over every site; queue per-site jobs from per-site code.

A new site created after network activation is handled by the same lazy path. If you need work done at creation time rather than first visit, hook wp_initialize_site at a priority later than 10, as the WordPress course showed.

Network settings

Some options apply to the whole network, such as a licence key or a default maximum number of guests. Network admin pages are registered on network_admin_menu. The Settings API's options.php handler does not save network options, so the form posts to edit.php with an action, and you handle it on network_admin_edit_{action}:

PHP
<?php
// myplugin/src/Admin/NetworkSettingsPage.php

namespace MyPlugin\Admin;

use MyPlugin\Contracts\Hookable;

defined( 'ABSPATH' ) || exit;

final class NetworkSettingsPage implements Hookable {

    private const OPTION = 'myplugin_network_settings';
    private const ACTION = 'myplugin_network_settings';

    public function register(): void {
        add_action( 'network_admin_menu', array( $this, 'add_page' ) );
        add_action( 'network_admin_edit_' . self::ACTION, array( $this, 'save' ) );
    }

    public function add_page(): void {
        add_submenu_page(
            'settings.php',
            __( 'Events network settings', 'myplugin' ),
            __( 'Events', 'myplugin' ),
            'manage_network_options',
            'myplugin-network',
            array( $this, 'render' )
        );
    }

    public function render(): void {
        $settings = (array) get_site_option( self::OPTION, array() );
        $url      = add_query_arg( 'action', self::ACTION, network_admin_url( 'edit.php' ) );
        ?>
        <div class="wrap">
            <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
            <?php if ( isset( $_GET['updated'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. ?>
                <div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Settings saved.', 'myplugin' ); ?></p></div>
            <?php endif; ?>
            <form method="post" action="<?php echo esc_url( $url ); ?>">
                <?php wp_nonce_field( self::ACTION ); ?>
                <label for="myplugin-default-max-guests"><?php esc_html_e( 'Default maximum guests', 'myplugin' ); ?></label>
                <input id="myplugin-default-max-guests" name="max_guests" type="number" min="1" max="20"
                    value="<?php echo esc_attr( (string) ( $settings['max_guests'] ?? 6 ) ); ?>">
                <?php submit_button(); ?>
            </form>
        </div>
        <?php
    }

    public function save(): void {
        check_admin_referer( self::ACTION );

        if ( ! current_user_can( 'manage_network_options' ) ) {
            wp_die( esc_html__( 'You are not allowed to change these settings.', 'myplugin' ), 403 );
        }

        $max_guests = isset( $_POST['max_guests'] ) ? absint( wp_unslash( $_POST['max_guests'] ) ) : 6;

        update_site_option( self::OPTION, array( 'max_guests' => max( 1, min( 20, $max_guests ) ) ) );

        wp_safe_redirect(
            add_query_arg(
                array(
                    'page'    => 'myplugin-network',
                    'updated' => 'true',
                ),
                network_admin_url( 'settings.php' )
            )
        );
        exit;
    }
}

Register it from Plugin::register() only when it applies: if ( is_multisite() && is_network_admin() ). Because no Settings API is involved, the handler does everything itself: nonce, capability, unslash, sanitise, save, redirect. manage_network_options is a super admin capability; a site administrator does not have it.

A per-site setting can then fall back to the network default:

PHP
<?php

$site    = (array) get_option( 'myplugin_settings', array() );
$network = is_multisite() ? (array) get_site_option( 'myplugin_network_settings', array() ) : array();
$max     = (int) ( $site['max_guests'] ?? $network['max_guests'] ?? 6 );

When a site is deleted

Core drops a site's core tables when the site is deleted, but it does not know about yours. Add the bookings table to the list with the wpmu_drop_tables filter, which receives the table names and the site ID:

PHP
<?php
// myplugin/src/Multisite/SiteLifecycle.php

namespace MyPlugin\Multisite;

use MyPlugin\Contracts\Hookable;

defined( 'ABSPATH' ) || exit;

final class SiteLifecycle implements Hookable {

    public function __construct( private \wpdb $wpdb ) {}

    public function register(): void {
        add_filter( 'wpmu_drop_tables', array( $this, 'add_tables' ), 10, 2 );
    }

    public function add_tables( array $tables, $site_id ): array {
        $tables[] = $this->wpdb->get_blog_prefix( (int) $site_id ) . 'myplugin_bookings';

        return $tables;
    }
}

get_blog_prefix( $site_id ) builds the prefix for the given site, whichever site is current when the filter runs. Register the class from Plugin::register() when is_multisite() is true.

Uninstalling across the network

uninstall.php runs once, on the main site. To remove data from every site, page through them rather than loading all at once:

PHP
<?php
// myplugin/uninstall.php

defined( 'WP_UNINSTALL_PLUGIN' ) || exit;

function myplugin_uninstall_site(): void {
    global $wpdb;

    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Removing the plugin's own table.
    $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'myplugin_bookings' ) );
    delete_option( 'myplugin_settings' );
    delete_option( 'myplugin_db_version' );
}

if ( ! is_multisite() ) {
    myplugin_uninstall_site();
    return;
}

$myplugin_offset = 0;

do {
    $myplugin_site_ids = get_sites(
        array(
            'fields' => 'ids',
            'number' => 100,
            'offset' => $myplugin_offset,
        )
    );

    foreach ( $myplugin_site_ids as $myplugin_site_id ) {
        switch_to_blog( $myplugin_site_id );
        myplugin_uninstall_site();
        restore_current_blog();
    }

    $myplugin_offset += 100;
} while ( count( $myplugin_site_ids ) === 100 );

delete_site_option( 'myplugin_network_settings' );
delete_site_option( 'myplugin_network_activated_at' );

On a very large network this can still be slow; for thousands of sites, document a WP-CLI alternative, such as running the per-site cleanup with wp site list and --url in a shell loop.

Avoiding expensive cross-site code

switch_to_blog() is not free. It swaps table prefixes, caches and the current site's options. Inside a loop over hundreds of sites, on a front-end request, it can add seconds. Rules of thumb:

  • Never loop over sites on a normal page load. Aggregate network-wide data in a background job and cache the result as a network option or in the object cache.
  • Always pair switch_to_blog() with restore_current_blog(), even when an exception is possible: use try and finally.
  • After switching, the site's options and tables are correct, but plugins active only on the target site are not loaded. Do not call their functions or expect their hooks.

Testing on Multisite

The WordPress test suite from lesson 18 runs in Multisite mode when the WP_MULTISITE environment variable is set:

Shell
WP_MULTISITE=1 vendor/bin/phpunit

Factories then create sites too, so you can test the per-site behaviour directly:

PHP
<?php
// myplugin/tests/Multisite/PerSiteTablesTest.php

namespace MyPlugin\Tests\Multisite;

use MyPlugin\Database\Migrator;
use WP_UnitTestCase;

/**
 * @group multisite
 */
class PerSiteTablesTest extends WP_UnitTestCase {

    public function test_each_site_gets_its_own_bookings_table(): void {
        if ( ! is_multisite() ) {
            $this->markTestSkipped( 'Multisite only.' );
        }

        global $wpdb;

        $site_id = self::factory()->blog->create();

        switch_to_blog( $site_id );
        ( new Migrator( $wpdb ) )->maybe_migrate();
        $booking_id = myplugin()->bookings()->create( 1, 1, 2 );
        restore_current_blog();

        $this->assertGreaterThan( 0, $booking_id );
        $this->assertNull( myplugin()->bookings()->find( $booking_id ) );
    }
}

The test creates a booking on a new site, then confirms the main site's table does not contain it. Run the suite in both modes in CI. Locally, keep a small Multisite install and use wp --url=site3.example.test to run WP-CLI commands against one site.

Summary

  • Classify data as per site or per network; per-site tables use $wpdb->prefix, shared tables $wpdb->base_prefix, and network settings get_site_option().
  • Read the table prefix at query time so repositories follow switch_to_blog().
  • Avoid looping over sites on network activation: per-site version checks let each site migrate itself on its next request.
  • Save network settings through network_admin_edit_{action} with a nonce and manage_network_options, and add custom tables to wpmu_drop_tables.
  • Page through sites in uninstall.php, keep switch_to_blog() out of front-end loops, and run tests with WP_MULTISITE=1.

Further reading