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:
| Data | Scope | Storage |
|---|---|---|
| Events, event meta | Per site | Each site's posts tables, automatically |
| Bookings | Per site | {$wpdb->prefix}myplugin_bookings, one table per site |
myplugin_settings | Per site | get_option() |
myplugin_db_version | Per site | get_option(), because each site's table has its own schema version |
| Licence key, network defaults | Per network | get_site_option() / update_site_option() |
The two prefixes do the work for tables:
$wpdb->prefixis the current site's prefix:wp_on the main site,wp_3_on site 3. Use it for per-site tables.$wpdb->base_prefixis 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
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
// 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
// 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
$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
// 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
// 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()withrestore_current_blog(), even when an exception is possible: usetryandfinally. - 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:
WP_MULTISITE=1 vendor/bin/phpunit
Factories then create sites too, so you can test the per-site behaviour directly:
<?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 settingsget_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 andmanage_network_options, and add custom tables towpmu_drop_tables. - Page through sites in
uninstall.php, keepswitch_to_blog()out of front-end loops, and run tests withWP_MULTISITE=1.