Building an Options Page for a WordPress 4.* Plugin
For me, building the options page for a WordPress plugin is the least attractive part of the whole endeavor. For much of the history of WordPress there wasn't much support in the framework for building these pages, so everyone rolled their own. Even now, pick any ten plugins and take a look under the hood at their options pages, and you'll find ten totally different approaches that integrate with the existing framework tools to different degrees, and are usually horribly formatted to boot. This just isn't a good area of development to be learning by looking over the code of others.
Now that WordPress does have framework help to simplify plugin option page construction, it unfortunately remains the case that the documentation and the guides found online are not all that helpful. They are largely incomplete or out of date, and there are few good examples to pick from. Having waded through all of this, here is a concise outline of the way in which near all simple plugins should be set up with a single options page. The plugin here is called example_plugin
. This doesn't cover splitting up the options page into tabbed sections, but that is easy enough to add after the fact; most of the guides found online include that much, at least.
<?php /** * A settings page example. To use this, replace "example_plugin" with the name * of your plugin. */ // Exit if accessed directly. if (!defined('ABSPATH')) { exit; } /** * Define the path to the options page. */ define('EXAMPLE_OPTIONS_PAGE_PATH', '/wp-admin/options-general.php?page=example_plugin'); // --------------------------------------------------------------------------- // Define the plugin options. // --------------------------------------------------------------------------- /** * The approach we are taking here is to have all of the options for the plugin * contained in one array. We'll store that array as a single WordPress * option. This simplifies a lot of things, and cuts down on the boilerplate. */ /** * We will need to define the default options array, since it won't be set the * first time it is needed. * * Here we'll add a couple of simple fields - an integer, and a list of strings * that will be stored as an indexed array. */ function example_plugin_default_options() { return array( 'example_plugin_integer_option' => 100, 'example_plugin_list_option' => array() ); } /** * Since we have a default options array, it is best to provide a helper * function to obtain the options for the plugin, in order to wrap the necessary * get_option() call. */ function example_plugin_get_options() { return get_option('example_plugin_options', example_plugin_default_options()); } /** * All options set in the WordPress administrative interface must be on the * whitelist. So we'll register our compound options array as * 'example_plugin_options'. * * The important thing here is to provide the sanitize_callback function, as * that function is where all of the error check and error message setting has * to happen. * * If you have unusual requirements, such as storing options in custom database * tables, then the sanitize_callback function is where that will have to happen * as well. */ function example_plugin_admin_init() { register_setting( 'example_plugin_options_group', 'example_plugin_options', array( 'type' => 'array', 'default' => example_plugin_default_options(), 'sanitize_callback' => 'example_plugin_sanitize_options' ) ); } add_action('admin_init', 'example_plugin_admin_init'); /** * The function for sanitizing options entered by the user. * * This actually has to do more than just that. It also must set error messages * and juggle whether or not to reject and replace specific values. * * It isn't an ideal situation, as some approaches to form UI are not possible * to implement via this interface. Whatever is returned from this method will * be set as the options value regardless of everything else, so about the best * that can be done is to adjust values or reject all changes. You can't, for * example, choose not to save the results, provide errors, and still show the * user-entered invalid values in the form when the page reloads. * * In the example here, we take the approach of discarding all entered data if * any of it is bad, and issuing appropriate error messages. When the page * reloads, the user will see the prior values. */ function example_plugin_sanitize_options($input) { $output = example_plugin_get_options(); $error = false; // example_plugin_integer_option if ( !isset($input['example_plugin_integer_option']) || !is_numeric($input['example_plugin_integer_option']) || intval($input['example_plugin_integer_option']) <= 0 ) { add_settings_error( 'example_plugin_options', 'example_plugin_integer_option', __('Posts per cron run must be a positive integer.', 'example_plugin') ); } // example_plugin_list_option // // Since this data is entered into a textarea, one value per line, some // processing is needed to convert it to an array, and then check each entered // value for validity. $list = array(); if ( isset($input['example_plugin_list_option']) ) { // Split on newlines and trim. $list = array_map( function ($regex) { return preg_replace('/^\s+|\s+$/ui', '', $regex); }, preg_split('/[\r\n]+/ui', $input['example_plugin_list_option']) ); // Remove empty lines. $list = array_filter( $list, function ($item) { return !empty($item); } ); // Sort in place. sort($list); // Validate each string. Here, as an example, a simple match is used. foreach ($list as $item) { if (preg_match('/^[a-zA-Z]{5,20}$/', $item)) { add_settings_error( 'example_plugin_options', 'example_plugin_integer_option', __('Invalid list item: ', 'example_plugin') . $item ); } } } // Only update the existing data in the absence of errors. if (!count(get_settings_errors('example_plugin_options'))) { $output['example_plugin_integer_option'] = intval($input['example_plugin_integer_option']); $output['example_plugin_list_option'] = $list; } return $output; } // --------------------------------------------------------------------------- // Define the options page. // --------------------------------------------------------------------------- /** * This function emits the HTML for the options page. */ function example_plugin_options_page_html() { // Don't proceed if the user lacks permissions. if (!current_user_can('manage_options')) { return; } $options = example_plugin_get_options(); // First show the error or update messages at the head of the page. settings_errors('example_plugin_messages'); ?> <div class="wrap"> <h1><?= esc_html(get_admin_page_title()); ?></h1> <form action="options.php" method="post"> <?php settings_fields('example_plugin_options_group'); ?> <table class="form-table"> <tr> <th scope="row"> <label for="example_plugin_options[example_plugin_integer_option]"> <?php esc_html_e('An integer value', 'example_plugin'); ?>: </label> </th> <td> <input id="example_plugin_options[example_plugin_integer_option]" name="example_plugin_options[example_plugin_integer_option]" value="<?php echo esc_attr($options['example_plugin_integer_option']); ?>" /> <p class="description"> <?php esc_html_e('Enter integers only.', 'example_plugin'); ?> </p> </td> </tr> <tr> <th scope="row"> <label for="example_plugin_options[example_plugin_list_option]"> <?php esc_html_e('A list of values', 'example_plugin'); ?>: </label> </th> <td> <textarea class="regular-text" id="example_plugin_options[example_plugin_list_option]" name="example_plugin_options[example_plugin_list_option]" rows="10" /><?php echo esc_html_e(implode("\r\n", $options['example_plugin_list_option'])); ?></textarea> <p class="description"> <?php esc_html_e('Add one value per line.', 'example_plugin'); ?> </p> </td> </tr> </table> <?php submit_button(__('Save Settings', 'example_plugin')); ?> </form> </div> <?php } // --------------------------------------------------------------------------- // Add the options page link to the admin menu. // --------------------------------------------------------------------------- /** * Add a link to the settings page into the settings submenu. */ function example_plugin_options_page() { add_options_page( __('Example Plugin Options', 'example_plugin'), __('Example Plugin', 'example_plugin'), 'manage_options', 'example_plugin', 'example_plugin_options_page_html' ); } add_action('admin_menu', 'example_plugin_options_page'); // --------------------------------------------------------------------------- // Add options page links to the plugin entry in the plugins list. // --------------------------------------------------------------------------- /** * Add a link to the options page to the plugin name block. */ function example_plugin_plugin_action_links($links, $file) { static $this_plugin; if (!$this_plugin) { $this_plugin = plugin_basename(__FILE__); } if ($file == $this_plugin) { $settings_link = '<a href="' . SUGGEST_LINK_OPTIONS_PAGE_PATH . '">' . __('Settings', 'example_plugin') . '</a>'; array_unshift($links, $settings_link); } return $links; } add_filter('plugin_action_links', 'example_plugin_plugin_action_links', 10, 2); /** * Add a link to the options page to the plugin description block. */ function example_plugin_register_plugin_links($links, $file) { if ($file == plugin_basename(__FILE__)) { $links[] = '<a href="' . SUGGEST_LINK_OPTIONS_PAGE_PATH . '">' . __('Settings', 'example_plugin') . '</a>'; } return $links; } add_filter('plugin_row_meta', 'example_plugin_register_plugin_links', 10, 2);
That is it in a nutshell. Given the example, it is fairly easy to expand it in the directions that need to be taken for most plugins: different field types, splitting the settings page into sections, and so forth.