🖧 The wacky world of network activated WordPress plugins in multisite

Multisite is arguably one of the weirdest features in WordPress. Making your plugin work well on multisite installs, both when it is network activated and when it is not, is one of the most fun things that you will have to do as a WordPress plugin developer. And by fun, I mean not fun. Not fun at all.

Introduced in 3.0, the multisite feature feels a little awkward when developing code, partly because things you take for granted in single-site installs are simply not there, partly because other things are unnecessarily different, partly because of the shifting terminology that has evolved over time, and partly because of the significantly less documentation, compared to that available on single-site features. Moreover there are significantly less articles about it out there, since most people care about single-site installs.

But, it works! After all, last time I checked, wordpress.com was up and running! The fact that people were able to extend WordPress so drastically in a way that actually works, is impressive to say the least. I can only imagine how hard it must have been. The idea is that you can make your WordPress behave just like wordpress.com, where users can create their own site.

So yes, some things about network-activated plugins on multisite suck, but they don’t suck to the point of being unusable. You just need to learn a few tricks. Fortunately, you came across this article, so all will become clear. Or will they?

Some basic terminology

A multisite (aka MS) install is sometimes also called a network. A network can have a multitude of sites, and sites can have many blogs. Confusingly, site can also mean blog besides network. And network does not mean what we normally mean in IT, it means a collection of sites or multi-sites, depending on who you ask. And a blog of course is not necessarily an actual weblog, but can be any type of site.

To add to the fun, in the past the multisite feature was named multiuser (or WPMU, or MU for short). Presumably this was changed because multiuser is a stupid name for this feature: all WordPress installations can have multiple users. But you do need to be aware of all the terms out there, because you will come across them at some point.

To paraphrase an old saying,

There are only two hard problems in computer science, cache invalidation and naming WordPress things.

Users who create blogs, or sites, or whatever (sigh!) on your multisite are administrators of their respective sites, and you, the owner of the network are a network admin, also known as a super admin. The network admin can administer global settings for the entire network from the network admin menu.

What does network activated actually mean?

Themes and plugins can either be:

  • activated on individual blogs by administrators, if the administrators have the capability to do so, or
  • network-activated once, site-wide, by the network admin, making them available to all blogs.

Custom DB tables

Most tables in the DB are duplicated for each blog, where the table name prefix contains the blog ID, so that, for example, options for blogs 3 and 4 are safely stored in tables wp_3_options and wp_4_options respectively. This allows for blog administrators to activate and configure their plugins separately for their blogs. As long as they activate the plugins themselves, everything should work just fine for most plugins, out of the box.

If you are maintaining custom DB tables, these will need some extra coding work. There are two ways to do this: Either

  • add a blog_id column to your rows, and then filter your SQL queries by blog_id=get_current_blog_id(), or
  • create separate tables for each blog when your plugin activates, by binding with the register_activation_hook() function, and also create tables whenever a new blog is created after your plugin has been activated, by binding to wpmu_new_blog.

You can read more about this here:

How To Properly Create Tables In WordPress Multisite Plugins

To network activate, or not to network activate?

Depending on what your plugin actually does, you might want to have parts of it operate site-wide. This often makes sense, but know that in doing so you might be opening a big can of worms. The network admin area introduces its own set of actions and filters, its own menu structure, its own URL structure, a somewhat lacking settings API where you have to do some things manually, and you will have to think about how your wp_cron hooks work, new user capabilities, and different plugin activation code.

The function is_multisite() tells you whether the WordPress install is multisite, but it tells you nothing about whether your plugin is activated for the network or whether it was activated at the blog level.

To find out whether your plugin is network activated or not, first import the right function:

if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
    require_once( ABSPATH . '/wp-admin/includes/plugin.php' );
}

Then, you can do this:

if ( is_plugin_active_for_network( 'myplugin/myplugin.php' ) ) { // do stuff }

assuming that your plugin’s slug is myplugin. The function will always return false on single-site installs.

So many options…

If some options must apply to the entire network, you will need to expose a menu of panels with those options to the network administrator.

Know your DB options

Those options can be thought of as “global” for a site (aka network), and they ought to be stored in the wp_sitemeta table. The table name is programmatically available as $wpdb->sitemeta. It is just like the wp_options table, but totally different: Columns option_name and option_value correspond to meta_key and meta_value. If you’re wondering about the site_id column, no, it’s not an identifier to individual blogs, it’s a unique identifier to a site, because, yes, you can have several sites on one network, each with several blogs.

:s/_option/_site_option/

These site meta options are accessed not by the usual functions get_option() and friends, but by get_site_option(), update_site_option(), add_site_option() and delete_site_option().

Option-related capabilities

You might be interested in the manage_network_options capability that is normally granted to Super Admins. In fact, have a look at Roles and Capabilities in the Codex to make sure you know about all of the manage_network_* capabilities, as well as the *_sites capabilities. Essential reading if you’re a network admin.

Updating site options

Know that you will have to do things differently when creating admin forms for network-wide options. In a nutshell:

  • you need to hook to the network_admin_menu action rather than admin_menu.
  • your HTML forms need to have a different action attribute,
  • you need to bind a submit handler to:
    • check for the admin nonce,
    • actually save the settings to the DB, and
    • redirect back to the right admin panel.
  • Use network_admin_url() wherever you’d normally use admin_url().

I will not go through all of this detail here, as it is explained very well in this rare article:

Using the WordPress Settings API with Network Admin pages

I find this article tells you all that you need to know about how to actually save site meta, but if you want to read more, you can have a look at the official documentation, which currently simply links to here and here.

Getting hooked to site-wide options yet? Not to worry, there’s more!

Keep in mind that there exists a multitude of hooks that are specific to network administration. For example, to do various custom stuff to a site-wide option before saving it to the DB, do not bind to pre_update_option_{$option_name}, but to pre_update_site_{$option_name}.

Set option values via the terminal like a 1334 h@x0r

If you’re like me, you probably have set up your build process to auto-inject options to your dev environment, using wp-cli. Instead of

 wp option update option_name option_value

do something like this instead:

wp network meta set 1 option_name option_value

where 1 is the number of your multisite environment.

How to nag the right admin

To show notices to the network admin, do not bind to admin_notices, instead bind to network_admin_notices.

The surprising secret argument of the activation hook that nobody knew about!!!

Apologies, when I’m blogging I sometimes go into full SEO mode. Anyhow…

For your activation handler to work, you will first need to bind a function with register_activation_hook(), as usual. It turns out that your activation handler can take a parameter that tells you whether the plugin is being network-activated or not.

Here’s a handy way I like to use to create options that can either be global on the multisite level, or bound to the current blog, depending on whether your plugin was network-activated or activated on a single blog:

function activation_hook( $network_active ) {
    call_user_func(
        $network_active ? 'add_site_option' : 'add_option',
        'option_name',
        'option_value'
    );
}
register_activation_hook( __FILE__, 'activation_hook' );

Clean up after yourself!

Remember to do the same in your uninstall.php. You do clean up after yourself, don’t you? Of course you do, you’re a developer, not a filthy pig.

Remember, you will not be able to use is_plugin_active_for_network(), because by the time the uninstall code runs, the plugin should have already been deactivated. Since the plugin is being uninstalled completely, you can do something like the following, just to be on the safe side:

// delete from site meta
delete_site_option( 'option_name' );

// also delete from options table for all blogs
global $wpdb;
foreach ( $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" ) as $blog_id ) {
    switch_to_blog( $blog_id );
    delete_option( 'option_name' );
    restore_current_blog();
}

You will also likely want to delete any custom tables you may have created. The trick here is to iterate over all the blogs and let WordPress choose the right DB prefix.

foreach ( $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" ) as $blog_id ) {
    switch_to_blog( $blog_id );
    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mytable" );
    restore_current_blog();
}

If you have chosen the separate tables way, you should also hook a DROP TABLE query to the delete_blog hook:

function delete_blog_tables( $blog_id, $drop ) {
    if ( $drop ) {
        switch_to_blog( $blog_id );
        $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mytable" );
        restore_current_blog();
    }
}
add_action( 'delete_blog', 'delete_blog_tables' );

If on the other hand you have one table for all blogs, and your rows have a blog_id column, do something more in the tune of:

function delete_blog_tables( $blog_id, $drop ) {
    if ( $drop ) {
        $wpdb->query( "DELETE FROM {$wpdb->prefix}mytable WHERE blog_id = $blog_id" );
    }
}
add_action( 'delete_blog', 'delete_blog_tables' );

There! All clean now!

Cron

You will want to use that handy little foreach loop in more than just the uninstall script. For instance, suppose you’re using cron. If your plugin is network activated, cron will run once for the network, not once per blog. Assuming your cron handler needs to do stuff to each blog, you’d do something like:

function cron_handler( ) {
    if ( is_plugin_active_for_network( 'myplugin/myplugin.php' ) ) {
        global $wpdb;
        foreach ( $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" ) as $blog_id ) {
            switch_to_blog( $blog_id );
            do_stuff();
            restore_current_blog();
        }
    } else {
        do_stuff();
    }
}
add_action( 'cron_hook', 'cron_handler' );

if ( false === wp_next_scheduled( 'cron_hook' ) ) {
    wp_schedule_event( time(), 'every_now_and_then', 'cron_hook' );
}

function do_stuff() {
    // do your stuff here
}

This way if your plugin runs network-wide, then your stuff is done on each one of the blogs on your multisite. But if the plugin is activated on the blog level, it does stuff only on the current blog.

Good stuff!

Super secret plugin header: Network

Edit: This plugin header seems to not be respected any more; perhaps this is why it is not documented. It is part of WordPress history.

Although you wouldn’t have guessed it from reading the relevant documentation, the plugin header, i.e. that special block of comments that you put at the top of your main plugin file, can have something to say about network activation. (A tiny hint is given in the Codex that such a header exists, although the documentation does not currently bother explaining what it does. After all, if everything was documented, where would the fun be?)

If your plugin is intended to be only network activated, and not activated on the blog-level in multi-sites, then add this line to your plugin’s header:

Network: True

Now WordPress will not give the activation option to mere blog admins.

Are you still with me?

Awesome! There you have it! Now you know all that I currently know about WordPress multisite and network activated plugins. Which is arguably not a lot. If you know more and feel like sharing, you’re free to comment below.

Hopefully you now have some grasp of the types of things you should be looking out for when you take your plugin for a ride into the wacky world of network activation.

Take care.

Alex

2 thoughts on “🖧 The wacky world of network activated WordPress plugins in multisite

  1. Iterate through every site to create/drop tables in a multisite can be a bad idea. I know it won’t happen in most of the multisites but if it’s big enough it will end in an execution timeout. Is there any good way to clean up then? Not in a multisite if you have created tables per site.

    My first impulse when developing for multisites is to create a network tables and use network meta instead but I know that sometimes you need more than that.

    1. You are of course correct. With enough blogs you could hit the timeout by looping.

      There are a number of reasons why you would want to iterate over all the blogs; not just creating and deleting options or tables. In that case I would imagine that one would have to create a table capturing all the pending operations, then perform those operations in batches with a cron job. But that is hard to do inside the uninstall script of a plugin.

      I guess there are no easy answers if you have a multisite with that many blogs.

Leave a Reply

Your email address will not be published. Required fields are marked *