One of the many great uses for the WordPress REST API is improving your plugin or theme settings screens. Once you add custom REST API endpoints, getting saved settings via AJAX and saving it via AJAX — IE with no additional page loads — is simpler.
Using the WordPress REST API instead of admin-ajax is not only more performant but also lets WordPress core do most of the heavy lifting in terms of sanitization and validation.
In this article, we will walk through each of the steps to create a settings form page and process that form using the WordPress REST API.
Adding Your Settings Page
Before we can get started designing our settings page, we will need to add a menu or submenu item to the WordPress dashboard that you can put a settings form on. On this page, you will need to load CSS and JavaScript files.
Here is a starter class for that:
<?php class Apex_Menu { /** * Menu slug * * @var string */ protected $slug = 'apex-menu'; /** * URL for assets * * @var string */ protected $assets_url; /** * Apex_Menu constructor. * * @param string $assets_url URL for assets */ public function __construct( $assets_url ) { $this->assets_url = $assets_url; add_action( 'admin_menu', array( $this, 'add_page' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'register_assets' ) ); } /** * Add CF Popup submenu page * * @since 0.0.3 * * @uses "admin_menu" */ public function add_page(){ add_menu_page( __( 'Apex Page', 'text-domain' ), __( 'Apex Page', 'text-domain' ), 'manage_options', $this->slug, array( $this, 'render_admin' ) ); } /** * Register CSS and JS for page * * @uses "admin_enqueue_scripts" action */ public function register_assets() { wp_register_script( $this->slug, $this->assets_url . '/js/admin.js', array( 'jquery' ) ); wp_register_style( $this->slug, $this->assets_url . '/css/admin.css' ); wp_localize_script( $this->slug, 'APEX', array( 'strings' => array( 'saved' => __( 'Settings Saved', 'text-domain' ), 'error' => __( 'Error', 'text-domain' ) ), 'api' => array( 'url' => esc_url_raw( rest_url( 'apex-api/v1/settings' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ) ) ) ); } /** * Enqueue CSS and JS for page */ public function enqueue_assets(){ if( ! wp_script_is( $this->slug, 'registered' ) ){ $this->register_assets(); } wp_enqueue_script( $this->slug ); wp_enqueue_style( $this->slug ); } /** * Render plugin admin page */ public function render_admin(){ $this->enqueue_assets(); echo 'Put your form here!'; } }
In this class, I am using add_menu_page to create a top level menu, but you might wish to substitute add_sub_menu instead, depending on your needs. There are two other important things to note.
The first is how we are using wp_localize_script(). This function gives you a way to create a global scope JavaScript variable using PHP, whenever the script specified in its first argument is loaded. This was originally designed to provide translated — localized — strings to the browser. That’s part of how we’re using it — to provide translatable success and error messages. But it can also be used to pass dynamic values, like URLs for the current site, in this case, a REST API endpoint and a nonce. We will need all of that in our JavaScript, but it will be different for every site, so we must use PHP to generate it on the fly.
Also, note that the root URL for the scripts is passed in as a dependency to the class. I like to do this as that URL is likely to be used in other places in a plugin or theme, and I want one single place to change or filter it for the whole plugin.
We will need to specify that URL when we instantiate the class. A good place to do it is in the root plugin file, so plugin_dir_url() will generate the right URL. Let’s look at the main plugin file that would set this up:
<?php /** * Plugin Name: Apex Plugin */ add_action( 'init', function(){ $assets_url = plugin_dir_url( __FILE__ ); //Setup menu if( is_admin() ){ new Apex_Menu( $assets_url ); } //Setup REST API });
In this, we use the “init” action to load this class. I left a placeholder for the REST API endpoint we will use to save our data once the plugin screen is ready for that.
The Settings Form
I’m not going to get too deep into the settings form itself, I could write a whole series on that. Instead, let’s just add two fields to look at a few important things. Then we can write the JavaScript to send the form values back to the server.
Here is the “render_admin” method, updated with a field that has two fields:
/** * Render plugin admin page */ public function render_admin(){ $this->enqueue_assets(); ?> <form id="apex-form"> <div id="feedback"> </div> <div> <label for="industry"> <?php esc_html_e( 'Industry', 'text-domain' ); ?> </label> <input id="industry" type="text" /> </div> <div> <label for="amount"> <?php esc_html_e( 'Amount', 'text-domain' ); ?> </label> <input id="amount" type="number" min="0" max="100" /> </div> <?php submit_button( __( 'Save', 'text-domain' ) ); ?> </form> <?php }
I made sure that each field had an ID. This will allow me to target each one with jQuery.val() to get its value. This is also important for keeping our HTML semantic, as field labels must have a for the attribute corresponding to a field ID. I also gave the form an ID and added an empty element with the ID of “feedback” that we can dynamically place the saved or error messages there.
Again, your form will probably be way more complex, but let’s start simple.
Adding A REST API Route
Before we can write any JavaScript to send the data to the server via AJAX, we need a REST API route for it. I’ve written a ton about this, but it’s worth going over a very simple custom route with a GET and POST endpoint.
Separating The Business Logic
I strongly believe that the classes for REST API routes should only be concerned with the handling requests, not the business logic needed for those requests. In this case, by “business logic” I mean reading and writing the actual settings. So first, let’s create a class that can handle that.
This class will be a simple wrapper around get_option() and update_option() with some basic validation. This class has a get_settings() method that gets the saved value and then uses wp_parse_args() to fill in any missing indexes of the saved array that we would expect it to have. It also has a save_settings() method that makes sure that only whitelisted keys of the array being saved are in the final array to be saved.
<?php class Apex_Settings { /** * Option key to save settings * * @var string */ protected static $option_key = '_apex_settings'; /** * Default settings * * @var array */ protected static $defaults = array( 'industry' => 'lumber', 'amount' => 42 ); /** * Get saved settings * * @return array */ public static function get_settings(){ $saved = get_option( self::$option_key, array() ); if( ! is_array( $saved ) || ! empty( $saved )){ return self::$defaults; } return wp_parse_args( $saved, self::$defaults ); } /** * Save settings * * Array keys must be whitelisted (IE must be keys of self::$defaults * * @param array $settings */ public static function save_settings( array $settings ){ //remove any non-allowed indexes before save foreach ( $settings as $i => $setting ){ if( ! array_key_exists( $setting, self::$defaults ) ){ unset( $settings[ $i ] ); } } update_option( self::$option_key, $settings ); } }
The REST API Route
Now that we have a way to read and write the settings that we can address via any means, let’s create a REST API route that acts as a RESTful interface for it. This route will have GET and POST endpoints.
If you’ve never created a custom REST API endpoint, I recommend that you read the documentation. I also have a curated list of links, including to Torque articles and WordPress TV talks on the topic.
Here is the REST API route class:
<?php class Apex_API { /** * Add routes */ public function add_routes( ) { register_rest_route( 'apex-api/v1', '/settings', array( 'methods' => 'POST', 'callback' => array( $this, 'update_settings' ), 'args' => array( 'industry' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field' ), 'amount' => array( 'type' => 'integer', 'required' => false, 'sanitize_callback' => 'absint' ) ), 'permissions_callback' => array( $this, 'permissions' ) ) ); register_rest_route( 'apex-api/v1', '/settings', array( 'methods' => 'GET', 'callback' => array( $this, 'get_settings' ), 'args' => array( ), 'permissions_callback' => array( $this, 'permissions' ) ) ); } /** * Check request permissions * * @return bool */ public function permissions(){ return current_user_can( 'manage_options' ); } /** * Update settings * * @param WP_REST_Request $request */ public function update_settings( WP_REST_Request $request ){ $settings = array( 'industry' => $request->get_param( 'industry' ), 'amount' => $request->get_param( 'amount' ) ); Apex_Settings::save_settings( $settings ); return rest_ensure_response( Apex_Settings::get_settings())->set_status( 201 ); } /** * Get settings via API * * @param WP_REST_Request $request */ public function get_settings( WP_REST_Request $request ){ return rest_ensure_response( Apex_Settings::get_settings()); } }
Take a look, the callback functions are pretty simple, as they just wrap the settings class I created in the last section. It’s important to understand that by design, that settings class has no permissions check or sanitization. But permissions checking and sanitization is very important.
These REST API endpoints provide that. The POST method specifies a ‘sanitize_callback’ argument for each field. That way I can trust that the data is safe before passing it in. Also, both routes use a “permissions_callback” that makes it so these routes can only be accessed via those with the “manage_options” capability. Skipping either of these steps would be dangerous.
Now we just need to instantiate this class, at the “rest_api_init” action so the endpoints exist. Here is the main plugin file again, modified to do that:
<?php /** * Plugin Name: Apex Plugin */ add_action( 'init', function(){ $assets_url = plugin_dir_url( __FILE__ ); //Setup menu if( is_admin() ){ new Apex_Menu( $assets_url ); } //Setup REST API });
Using The REST API In Your Settings Page
Now that we have the endpoints, let’s put them to use on our settings page. We are going to write two AJAX calls. The first will get the saved settings and update the form with those settings. This will be triggered by the page loading. The second will be triggered by the form save button and will be used to update the settings.
Earlier on in this tutorial, we told WordPress to load a JavaScript file on this admin page. It’s now time to use that file.
Here is what the first AJAX call looks like. Its job is to get the saved settings and update the form with those:
jQuery(function($) { $.ajax({ method: 'GET', url: APEX.api.url beforeSend: function ( xhr ) { xhr.setRequestHeader( 'X-WP-Nonce', APEX.api.nonce ); } }).then( function ( r ) { if( r.hasOwnProperty( 'industry' ) ){ $( '#industry' ).val( r.industry ); } if( r.hasOwnProperty( 'amount' ) ){ $( '#amount' ).val( r.amount ); } }) });
Notice two important things here. First, I use the APEX global object that was setup earlier with wp_localize_script() to tell jQuery what URL to request. Also, I’m using the beforeSend() method to add a header with the nonce contained in that object. Without that, during the API request processing the user will not be considered logged in and therefore our permissions check will fail.
After the API request is completed, the settings are added to the form fields using jQuery.val(). For safety’s sake, I made sure they were in the response using Object.hasOwnProperty(). This is important validation but doesn’t scale well as the amount of settings grows — one of the many reasons I use VueJS for this sort of thing.
Now when you load the page, it should get the saved settings, probably the default values at this point and update the form. That’s good, but the real point of all of this is to be able to update the settings. So we will need our second AJAX call, which will run a POST request when the form is submitted.
Here is the updated JavaScript with the save AJAX call:
jQuery(function($) { $.ajax({ method: 'GET', url: APEX.api.url, beforeSend: function ( xhr ) { xhr.setRequestHeader( 'X-WP-Nonce', APEX.api.nonce ); } }).then( function ( r ) { if( r.hasOwnProperty( 'industry' ) ){ $( '#industry' ).val( r.industry ); } if( r.hasOwnProperty( 'amount' ) ){ $( '#amount' ).val( r.amount ); } }); $( '#apex-form' ).on( 'submit', function (e) { e.preventDefault(); var data = { amount: $( '#amount' ).val(), industry: $( '#industry' ).val() }; $.ajax({ method: 'POST', url: APEX.api.url, beforeSend: function ( xhr ) { xhr.setRequestHeader('X-WP-Nonce', APEX.api.nonce); }, data:data }).then( function (r) { $( '#feedback' ).html( '<p>' + APEX.strings.saved + '</p>' ); }).error( function (r) { var message = APEX.strings.error; if( r.hasOwnProperty( 'message' ) ){ message = r.message; } $( '#feedback' ).html( '<p>' + message + '</p>' ); }) }) });
This second call is very similar, except it uses POST and is wrapped in a closure bound to the submit event of the form. That way it runs when the form submits and we can prevent the default action of that event from happening.
What you should look at here are the success and error methods. They are used to add messages as text, localized in the APEX.string object into the #feedback element. The “error” message in that object is very general. But most failed requests will generate a response with a message. So, if that is set we will use that instead.
Make It Your Own
Once you have a good starting point you can update the fields in the forms to match your needs. Also, you should probably use a JavaScript framework to simplify this, because as your form grows in complexity, you’re going to be doing more and more with jQuery that is a pain to manage and would be simpler, and provide a better user experience if you used VueJS or React.
This article has shown you all of the parts of adding a WordPress setting page that uses the WordPress REST API. We added a menu page, enqueued our JavaScript and CSS, added a class to read and write the settings, added two REST API endpoints as a RESTful and secured interface for those settings, and used jQuery AJAX to update the settings based on our settings form. That’s a lot, but I hope you’ve seen how you can improve your own settings pages using these basics or build your own from scratch and grow from there.
14 Comments