API Settings for Network Sites (Multisite)

In WordPress, there is a special API that allows you to quietly and effortlessly create settings (options) pages, such as those for a plugin or theme. The data for these options is stored in the options table. The API enables the quick creation of such pages with protection and in the logic and design of WordPress itself, meaning that when using the API, there is no need to separately worry about the HTML code and CSS styles of the form, the necessary protection, and the overall logic of the code — all of this is handled by the API.

A separate article is dedicated to the API settings, which explains how it works and how to use this API.

This note is a supplement to the aforementioned article and explains how to create a settings page in the "network sites" menu (multisite admin).

The thing is that when activating MU mode, separate sites appear with the familiar admin interface, and a "Network Management" section (network admin) is created, where the entire network is configured.

Network site admin: /wp-admin/network

So, the options API by default is not designed for use in network settings. To be precise, the main logic of the API can be used there, but "some things" need to be taken into account and completed. We will discuss this "some things" in this note.

How to Use WordPress "Options API" on Network Sites Admin Pages (Multisite)

When trying to solve such a task using Google, I usually stumbled upon clumsy and inelegant solutions, which I believe the current solution is not.

The Essence of the Problem

In the standard version, an option is registered using register_setting(), and its values are sent via a POST request to the file /wp-admin/options.php. After that, WordPress does everything for us: it retrieves, protects, processes, and saves the option data in the wp_options table. However, in a multisite network, options need to be saved in a different table — wp_sitemeta, and this is not what the API logic is designed for... A ticket was created on this issue, but a solution out of the box has not yet appeared; however, it is not particularly needed, because the task can be easily solved with some thought.

Let me remind you of the differences. In the MU build of WordPress, options for a separate site are processed by functions get_option() and update_option(), while network options are processed through get_network_option()/get_site_option() and update_network_option()/update_site_option().

The fundamental difference is that the data is saved in different tables, and each site has its own settings, while network settings are common for all sites.

To avoid writing a lot of text that will make your head spin, I will show the difference in comparison. For this, I will show how a standard settings page is created and how a similar page is created for network settings. About 10% of the code changes...

A Regular Settings Page, as the Options API Prescribes:

<?php
## Create a plugin settings page
add_action( 'admin_menu', 'add_plugin_page' );
function add_plugin_page(){
	add_options_page( 'Plugin Settings', 'My Plugin', 'manage_options', 'myplug_slug', 'options_page_html' );
}

## HTML code for the page
function options_page_html(){
	?>
	<div class="wrap">
		<h2><?php echo get_admin_page_title() ?></h2>

		<form action="options.php" method="POST">
			<?php
			settings_fields( 'option_group' );     // hidden protection fields - nonce

			do_settings_sections( 'myplug_page' ); // sections with settings (options). We have only one 'section_id'

			submit_button();
			?>
		</form>
	</div>
	<?php
}

## Registering settings. Settings will be stored in an array, not one setting = one option
add_action( 'admin_init', 'my_plugin_settings' );
function my_plugin_settings(){
	// create an option
	register_setting( 'option_group', 'option_name', 'sanitize_callback' );

	// create a section with fields
	add_settings_section( 'section_id', 'Main Settings', '', 'myplug_page' );

	// create fields for the section
	$opt_name = 'my_option';
	add_settings_field( $opt_name, 'My Option', 'fill_field', 'myplug_page', 'section_id', $opt_name );

	$opt_name = 'my_option_two';
	add_settings_field( $opt_name, 'My Second Option', 'fill_field', 'myplug_page', 'section_id', $opt_name );
}

## Filling Option 1
function fill_field( $opt_name ){
	$opts      = get_option( 'option_name' );
	$name_attr = "option_name[$opt_name]";
	$val       = isset( $opts[ $opt_name ] ) ? $opts[ $opt_name ] : null;

	if( $opt_name === 'my_option' ){
		echo '<input type="text" name="'. $name_attr .'" value="'. esc_attr( $val ) .'" />';
	}

	if( $opt_name === 'my_option_two' ){
		echo '<label><input type="checkbox" name="'. $name_attr .'" value="1" '. checked( 1, $val, 0 ) .' /> checked or not?</label>';
	}
}

## Cleaning saved data
function sanitize_callback( $options ){
	foreach( $options as $name => & $val ){
		if( $name == 'my_option' )     $val = sanitize_text_field( $val );

		if( $name == 'my_option_two' ) $val = intval( $val );
	}

	return $options;
}

As a result, we will get such a page:

The Same Settings Page, but for Network Sites:

<?php
## NETWORK - Create a plugin settings page
add_action( 'network_admin_menu', 'add_plugin_page' );
function add_plugin_page(){
	add_submenu_page( 'settings.php', 'Plugin Settings', 'My Plugin', 'manage_options', 'myplug_slug', 'options_page_html' );
}

## HTML code for the page
function options_page_html(){
	?>
	<div class="wrap">
		<h2><?php echo get_admin_page_title() ?></h2>

		<form action="edit.php?action=myplug_options" method="POST">
			<?php
			wp_nonce_field( 'myplug_nonce' ); // NETWORK - settings_fields() is not suitable for multisite...

			do_settings_sections( 'myplug_page' ); // sections with settings (options). We have only one 'section_id'

			submit_button();
			?>
		</form>
	</div>
	<?php
}

## Registering settings. Settings will be stored in an array, not one setting = one option
add_action( 'admin_init', 'my_plugin_settings' );
function my_plugin_settings(){
	// NETWORK - catch option updates through the hook 'network_admin_edit_(action)'
	if( is_multisite() )
		add_action( 'network_admin_edit_'.'myplug_options', 'myplug_options_update' );

	// create an option
	register_setting( 'option_group', 'option_name', 'sanitize_callback' );

	// create a section with fields
	add_settings_section( 'section_id', 'Main Settings', '', 'myplug_page' );

	// create fields for the section
	$opt_name = 'my_option';
	add_settings_field( $opt_name, 'My Option', 'fill_field', 'myplug_page', 'section_id', $opt_name );

	$opt_name = 'my_option_two';
	add_settings_field( $opt_name, 'My Second Option', 'fill_field', 'myplug_page', 'section_id', $opt_name );
}
## Filling Option 1
function fill_field( $opt_name ){
	$opts      = get_site_option( 'option_name' ); // NETWORK - not get_option()

	$name_attr = "option_name[$opt_name]";
	$val       = isset( $opts[ $opt_name ] ) ? $opts[ $opt_name ] : null;

	if( $opt_name === 'my_option' ){
		echo '<input type="text" name="'. $name_attr .'" value="'. esc_attr( $val ) .'" />';
	}

	if( $opt_name === 'my_option_two' ){
		echo '<label><input type="checkbox" name="'. $name_attr .'" value="1" '. checked( 1, $val, 0 ) .' /> checked or not?</label>';
	}
}

## Cleaning saved data
function sanitize_callback( $options ){
	foreach( $options as $name => & $val ){
		if( $name == 'my_option' )     $val = sanitize_text_field( $val );

		if( $name == 'my_option_two' ) $val = intval( $val );
	}

	return $options;
}

## NETWORK - updating options in the database
function myplug_options_update(){
	// nonce check
	check_admin_referer( 'myplug_nonce' );

	update_site_option( 'option_name', wp_unslash( $_POST['option_name'] ) );

	wp_redirect( network_admin_url( 'settings.php?page=myplug_slug&updated=true' ) );
	exit;
}

As a result, we will get such a page:

Differences

I will outline all the differences so that you don’t have to suffer and search for them, some of which you may not notice as a result.

  1. The hook network_admin_menu instead of admin_menu for registering the options page.

  2. add_submenu_page( 'settings.php' instead of add_options_page( - creating a subpage in another menu.

  3. <form action="edit.php?action=myplug_options" instead of <form action="options.php" - sending a POST request to the file /network/edit.php - this is a special file for such tasks.

  4. wp_nonce_field() instead of settings_fields() - for protection. The native API function is not suitable here...

  5. get_site_option() instead of get_option() - saving options in a different table.

  6. A hook network_admin_edit_(action) was added along with the function for this hook - myplug_options_update(), which saves our options using update_site_option(). For the network, the automatic interception of the request and its processing that the API provides does not work.

Here is how these differences look:

<?php
## NETWORK - Create a plugin settings page
add_action( 'network_admin_menu', 'add_plugin_page' );
function add_plugin_page(){
	add_submenu_page( 'settings.php', 'Plugin Settings', 'My Plugin', 'manage_options', 'myplug_slug', 'options_page_html' );
}

## HTML code for the page
function options_page_html(){
	?>
	<form action="edit.php?action=myplug_options" method="POST">
		<?php
		wp_nonce_field( 'myplug_nonce' ); // NETWORK - settings_fields() is not suitable for multisite...

		// ...
		?>
	</form>
	<?php
}

## Registering settings. Settings will be stored in an array, not one setting = one option
add_action( 'admin_init', 'my_plugin_settings' );
function my_plugin_settings(){
	// NETWORK - catch option updates through the hook 'network_admin_edit_(action)'
	if( is_multisite() )
		add_action( 'network_admin_edit_'.'myplug_options', 'myplug_options_update' );

	// ...
}

## Filling Option 1
function fill_field( $opt_name ){
	$opts      = get_site_option( 'option_name' ); // NETWORK - not get_option()

	// ...
}

## NETWORK - updating options in the database
function myplug_options_update(){
	// nonce check
	check_admin_referer( 'myplug_nonce' );

	update_site_option( 'option_name', wp_unslash( $_POST['option_name'] ) );

	wp_redirect( network_admin_url( 'settings.php?page='. 'myplug_slug' .'&updated=true' ) );
	exit;
}