How to write a multisite compatible WordPress plugin

In this tutorial I take you through the steps to write a multisite compatible WordPress plugin. But before we begin, please take note that writing plugins for large networks must be done with great care to resources. The plugin I’ve written is intended for a small network which can cope with additional tables for each site, but if you have a large network and/or limited hosting resources, this could quickly crash the network. If your plugin is intended for general release, I recommend you add information for network admins about the implications of installing & activating the plugin so they can evaluate if an install is safe to run.

For the purpose of this tutorial it is assumed that you are running WordPress Multisite with the plugin menu activated on all sites. The plugin structure uses the WP plugin boilerplate generator, https://wppb.me/, by Enrique Chávez which is based on the template written by Tom McFarlin, maintained by Devin Vinson.

Activation

When the plugin is activated, I need to set up all settings, cron events and custom tables needed for the plugin. Activation can either be networkwide, or local. In my plugin I need to set up an option which controls the cron time, the cron event which runs once daily at the time defined in the option and a custom table.

class Foo_Activator {
	
  public static $foo_db_version = '1.2.2';

  public static function activate( $network_wide ) {
		
    global $wpdb; 
			
    if ( is_multisite() &&  $network_wide ) {
		    
      // Get all blogs in the network and activate plugin on each one
      $blog_ids = $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" );
	        
      foreach ( $blog_ids as $blog_id ) {	        
        switch_to_blog( $blog_id ); 
         
        self::create_table(); 		
        self::set_options(); 			
        self::schedule_cron();    
   
        restore_current_blog();
      }
	        
    } else {
		    
      self::create_table(); 	
      self::set_options(); 		
      self::schedule_cron(); 
	        
    }		
		
  } // ENDS ACTIVATE() 
		
  private static function create_table() {
		
    global $wpdb;	
    $installed_ver = get_option( "foo_db_version" );
		
    if ( empty($installed_ver) || $installed_ver != self::$foo_db_version ) {
	
      $table_name = $wpdb->prefix . 'foo_table';		
      $charset_collate = $wpdb->get_charset_collate();	
      $sql = "CREATE TABLE $table_name (
        id mediumint(9) NOT NULL AUTO_INCREMENT,
        ...........
        PRIMARY KEY  (id),
        KEY post_id (post_id)
      ) $charset_collate;";
      require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );	
      dbDelta( $sql );
			
      update_option( "foo_db_version", self::$foo_db_version);
		
    }
	
  }	// END CREATE_TABLE() 
	
  // set options 
  private static function set_options() {
		
    // time when our cron runs 
    update_option('foo_cron_offset', '8.50');
		
  } // END SET OPTIONS
	
  private static function schedule_cron() {
		
    $offset = get_option( 'foo_cron_offset' ); // Hours in decimal format

    // first cron runs tomorrow 
    $time = strtotime('tomorrow'); //returns tomorrow midnight
    $time = $time + ( $offset * 60 * 60 ); // Midnight GMT + x seconds 
    wp_schedule_event($time, 'daily', 'foo_cron_event');	// this is our hook for sending emails 	
		
  } // END SCHEDULE_CRON()
	
	
  // Running setup whenever a new blog is created
  public static function add_blog( $params ) {
		
    if ( is_plugin_active_for_network( 'woocommerce-follow-up-emails/woocommerce-follow-up-emails.php' ) ) {
		    
      switch_to_blog( $params->blog_id );
	        
      self::create_table();		
      self::set_options(); 		
      self::schedule_cron(); 
	        
      restore_current_blog();
	        
     }
  }	
	

} // END CLASS


The function add_blog() at the end is run when a new site is added to the network and the plugin is networkwide activated. When this happens, I need to set up everything the plugin needs such as options, cron event and tables. The site activation gives me the site object, which allows me to switch to the site ID.

Deactivation

Deactivation can either occur within the network admin if the plugin is activated network wide; or locally within a site’s admin if activated locally. As extra complication a plugin can also be both network and locally activated; in which case WordPress will honour the local activation and keep the plugin locally active even when network deactivation is carried out.

In my plugin I’m following the WP way that local activation takes precedence over network deactivation. I also assume that deactivation is a reversible action hence deactivation does not delete any custom data associated with the plugin; but only stops the plugin from carrying out its functions.

class Foo_Deactivator {
	
  public static function deactivate( $network_wide ) {
		
    global $wpdb; 
			
    if ( is_multisite() &&  $network_wide ) {
		    
      // Get all blogs in the network and deactivate cron on each 
      $blog_ids = $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" );
	        
      foreach ( $blog_ids as $blog_id ) {
		        
        switch_to_blog( $blog_id );
	            
          // if the plugin was previously activated locally we honour the local decision and leave it in place  
          // this follows the same logic as deactivate_plugins() in /wp-admin/includes/plugin.php line 734 
          if ( !in_array( 'foo/foo.php', (array) get_option( 'active_plugins', array() ) )  ) {					
            self::unschedule_cron();					
          }
	            
        restore_current_blog();
      }
	        
    } else {	    
      self::unschedule_cron();         
    }		

  } // ENDS DEACTIVATE() 

	
  public static function unschedule_cron() {		
    $timestamp = wp_next_scheduled(  'foo_cron_event' );			
    $result = wp_unschedule_event( $timestamp,  'foo_cron_event');				
  } // END UNSCHEDULE_CRON()	

	
  // run when a blog is removed from network 
  public static function remove_blog( $params ) {
			
    global $wpdb;
    switch_to_blog( $params->blog_id );		

    // options and cron events are removed automatically on site deletion 
    // but we also need to delete our custom table, let's drop it 
    $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}foo_table");      
        
    restore_current_blog();
		
    } // END REMOVE_BLOG()	
	

} // END CLASS 

In the above code I check if I have a multisite. If it’s multisite, I go through each site and disable the cron job used by the plugin. I don’t delete any options or tables as I don’t want to permanently remove any data used by the plugin. The intention is that upon re-activation all previous data remains intact.

Towards the end of the class I have a function remove_blog(). This function is called when a site is deleted from the network. It passes the site object which gives me the site ID. Any data stored in default WP tables such as options and cron events are automatically deleted, so in my plugin I only need to take care to delete the custom table.

Uninstall

Uninstallation is straight forward. A plugin can only be deleted from within the network admin if it’s neither network activated, nor locally active on any of the sites. Deletion from the site admins is not possible.

Within uninstall we want to remove all traces of my plugin. These can include:

  • options
  • cron events
  • custom tables
  • and anything else the plugin installed during activation
global $wpdb; 
	
// If we are in multisite we delete for all blogs (deletion = network level) 
if ( is_multisite() ) {
	   
  // Get all blogs in the network and delete tables on each one
  $blog_ids = $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" );
    
  foreach ( $blog_ids as $blog_id ) {
        
    switch_to_blog( $blog_id );
        
    foo_uninstall_plugin(); 
		
    restore_current_blog();
  }
    
} else {
	
  foo_uninstall_plugin(); 
    
}

function foo_uninstall_plugin() {

  global $wpdb; 

  // Stop cron
  $timestamp = wp_next_scheduled(  'foo_cron_event' );			
  wp_unschedule_event( $timestamp,  'foo_cron_event');	        
        
  // Let's remove all options 
  foreach ( wp_load_alloptions() as $option => $value ) {		
    if ( strpos( $option, 'foo_' ) === 0 ) {	        
      // for site options in Multisite
      delete_option($option);   			     
    }
  };
        
  // let's drop the table 
  $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}foo_table");	
	
}

Let’s walk through the above code. First I check if I’m on a multisite. If yes, I switch to each site, and delete the plugin settings for each site separately. If I’m not on multisite, I can skip this step and just delete.

Next I have a function that handles my deletions. My plugin has a cron event, so first I remove this. Then my plugin has options, all of which start with a unique identifier, in my case “foo_”, and I delete all options that start with it. Lastly, I delete a custom table my plugin has created.

Done. The site is now tidied up and all information related to my plugin has been deleted.

Core plugin file

The core plugin file contains all the code necessary to get my plugin working. It registers the activation and deactivation code, includes the main plugin class, runs the plugin, registers the hooks for network site deletion and addition, and checks if WooCommerce is installed (in my case, WooCommerce is a required dependency but as WP doesn’t do dependencies I need to write my own code to handle the check).

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
	die;
}

define( 'FOO_VERSION', '1.0.0' );

function activate_foo( $network_wide ) {
  require_once plugin_dir_path( __FILE__ ) . 'includes/class-foo-activator.php';
  Foo_Activator::activate( $network_wide );
}
register_activation_hook( __FILE__, 'activate_foo' );

function deactivate_foo( $network_wide ) {
  require_once plugin_dir_path( __FILE__ ) . 'includes/class-foo-deactivator.php';
  Foo_Deactivator::deactivate( $network_wide );
}
register_deactivation_hook( __FILE__, 'deactivate_foo' );

require plugin_dir_path( __FILE__ ) . 'includes/foo.php';

function run_foo() {
		
  $need = false;
		
  if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
    require_once( ABSPATH . '/wp-admin/includes/plugin.php' );
  }
	
  // multisite 
  if ( is_multisite() ) {
    // this plugin is network activated - Woo must be network activated 
    if ( is_plugin_active_for_network( plugin_basename(__FILE__) ) ) {
      $need = is_plugin_active_for_network('woocommerce/woocommerce.php') ? false : true; 
      // this plugin is locally activated - Woo can be network or locally activated 
    } else {
      $need = is_plugin_active( 'woocommerce/woocommerce.php')  ? false : true; 	
    }
  // this plugin runs on a single site	
  } else {
    $need =  is_plugin_active( 'woocommerce/woocommerce.php') ? false : true; 	
  }
	
  if ($need === true) {
    add_action( 'admin_notices', 'need_woocommerce' );
    return; 
  }
		
  add_action('init' , function (){
	    
    $plugin = new Foo();
		
    $plugin->run();
		
    // if we have a new blog on a multisite let's set it up
    add_action( 'wp_insert_site', 'foo_run_multisite_new_site');		
		
    //if a blog is removed, let's remove the settings 
   add_action( 'wp_uninitialize_site', 'foo_run_multisite_delete');		
		
  });	

}

run_foo();

// When a new blog is added to the network 
function foo_run_multisite_new_site($params) {
  require_once plugin_dir_path( __FILE__ ) . 'includes/class-foo-activator.php';	
  Foo_Activator::add_blog($params);	
}

// When a blog is removed from the network 
function foo_run_multisite_delete($params) {
  require_once plugin_dir_path( __FILE__ ) . 'includes/class-foo-deactivator.php';
  Foo_Deactivator::remove_blog($params);		
}

function need_woocommerce() {
  $error = sprintf( __( 'The plugin "Foo" requires WooCommerce. Please install and active the  %sWooCommerce%s plugin. ' , 'foo' ), '<a href="http://wordpress.org/extend/plugins/woocommerce/">', '</a>' );
	
  $message = '<div class="error"><p>' . $error . '</p></div>';

  echo $message;
}


Let’s step through this. First I set the plugin version, and register the activation and deactivation functions with the WP hooks. Uninstall I don’t need to register as WP will execute the file with name “uninstall.php” automatically.

Then I include the main plugin class file and write the function that runs the plugin. In this function, I check if WooCommerce is active which is required for my plugin to work. When checking for Woo, I need to consider the following scenarios: If we’re on multisite, and my plugin is networkwide, then Woo must also be networkwide activated. If my plugin is local, then Woo can either be networkwide or locally active. Lastly, if my plugin runs on a single site install, then I only need to check this. If Woo cannot be detected, a notice is shown in WP Admin and my plugin will not run.

For my plugin, I add the running of the plugin to the “init” hook. Which WP hook is right for you will depend on what your plugin does. In my case, the plugin is intended to use the Woo emails, and WC_emails() instantiates with “init”, so my plugin mustn’t run too early. In the same code, I link my code to the hooks for site deletion and site additions.

Further Reading

The following resources helped me when I wrote my very first WP multisite plugin:

The End

I hope that this tutorial has shown how a multisite compatible WordPress plugin can work. If you have other ways to handle the code, comments or queries, please get in touch via the comments below!

Leave a Comment

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