Settings API with arrays example

Short answer: your name attribute values must use the schema option_name[array_key]. So, when you use …

<input name="option_name[key1]">
<input name="option_name[key2]">

… you get an array as option value in your validation function:

array (
    'key1' => 'some value',
    'key2' => 'some other value'
)

PHP does that for you, this is not a WordPress feature. 🙂

How to make that work with the settings API?

Let’s say, we want this options page, and all values should be stored in one option and validated in one function.

enter image description here

The options page

We need the hook admin_menu and two functions: one to register the page, one to render the output.

add_action( 'admin_menu', 't5_sae_add_options_page' );

function t5_sae_add_options_page()
{
    add_options_page(
        'T5 Settings API Example', // $page_title,
        'T5 SAE',                  // $menu_title,
        'manage_options',          // $capability,
        't5_sae_slug',             // $menu_slug
        't5_sae_render_page'       // Callback
    );
}

function t5_sae_render_page()
{
    ?>
    <div class="wrap">
        <h2><?php print $GLOBALS['title']; ?></h2>
        <form action="options.php" method="POST">
            <?php 
            settings_fields( 'plugin:t5_sae_option_group' );
            do_settings_sections( 't5_sae_slug' ); 
            submit_button(); 
            ?>
        </form>
    </div>
    <?php
}

The form action must be options.php, or the validation will not be called. Look at the PHP source of wp-admin/options-permalink.php – there is a hidden trap do_settings_sections('permalink'); – but it cannot work because the form action is wrong.

Now, back to our custom page. We make it better than WordPress.

Register settings, sections and fields

We hook into admin_init when we need it and call a registration function.

if ( ! empty ( $GLOBALS['pagenow'] )
    and ( 'options-general.php' === $GLOBALS['pagenow']
        or 'options.php' === $GLOBALS['pagenow']
    )
)
{
    add_action( 'admin_init', 't5_sae_register_settings' );
}

The important part here is: $GLOBALS['pagenow'] must be either options-general.php (for the output) or options.php (for the validation). Do not call all of the following code on each request. Most tutorials and almost all plugins get this wrong.

Okay, let’s register like crazy:

  1. We fetch the option values for our page and parse them against some defaults. Pretty basic.

  2. We register a settings group with the name plugin:t5_sae_option_group. I like prefixed names, they are easier to sort and to understand this way.

  3. Then we register two sections, 1 and 2.

  4. And we add three sections, two for the first section, one for the second. We pass the option name and the escaped value to the callback functions for each field. Output handlers should not change data, just add some HTML.

function t5_sae_register_settings()
{
    $option_name="plugin:t5_sae_option_name";

    // Fetch existing options.
    $option_values = get_option( $option_name );

    $default_values = array (
        'number' => 500,
        'color'  => 'blue',
        'long'   => ''
    );

    // Parse option values into predefined keys, throw the rest away.
    $data = shortcode_atts( $default_values, $option_values );

    register_setting(
        'plugin:t5_sae_option_group', // group, used for settings_fields()
        $option_name,  // option name, used as key in database
        't5_sae_validate_option'      // validation callback
    );

    /* No argument has any relation to the prvious register_setting(). */
    add_settings_section(
        'section_1', // ID
        'Some text fields', // Title
        't5_sae_render_section_1', // print output
        't5_sae_slug' // menu slug, see t5_sae_add_options_page()
    );

    add_settings_field(
        'section_1_field_1',
        'A Number',
        't5_sae_render_section_1_field_1',
        't5_sae_slug',  // menu slug, see t5_sae_add_options_page()
        'section_1',
        array (
            'label_for'   => 'label1', // makes the field name clickable,
            'name'        => 'number', // value for 'name' attribute
            'value'       => esc_attr( $data['number'] ),
            'option_name' => $option_name
        )
    );
    add_settings_field(
        'section_1_field_2',
        'Select',
        't5_sae_render_section_1_field_2',
        't5_sae_slug',  // menu slug, see t5_sae_add_options_page()
        'section_1',
        array (
            'label_for'   => 'label2', // makes the field name clickable,
            'name'        => 'color', // value for 'name' attribute
            'value'       => esc_attr( $data['color'] ),
            'options'     => array (
                'blue'  => 'Blue',
                'red'   => 'Red',
                'black' => 'Black'
            ),
            'option_name' => $option_name
        )
    );

    add_settings_section(
        'section_2', // ID
        'Textarea', // Title
        't5_sae_render_section_2', // print output
        't5_sae_slug' // menu slug, see t5_sae_add_options_page()
    );

    add_settings_field(
        'section_2_field_1',
        'Notes',
        't5_sae_render_section_2_field_1',
        't5_sae_slug',  // menu slug, see t5_sae_add_options_page()
        'section_2',
        array (
            'label_for'   => 'label3', // makes the field name clickable,
            'name'        => 'long', // value for 'name' attribute
            'value'       => esc_textarea( $data['long'] ),
            'option_name' => $option_name
        )
    );
}

All those callback handlers for the sections and fields will be called automatically when we call do_settings_sections( 't5_sae_slug' ); in our page. We did that already, so we need just to …

Print the fields

Note how the name attributes are built: the passed option_name is the first part, the array key follows in square brackets [].

function t5_sae_render_section_1()
{
    print '<p>Pick a number between 1 and 1000, and choose a color.</p>';
}
function t5_sae_render_section_1_field_1( $args )
{
    /* Creates this markup:
    /* <input name="plugin:t5_sae_option_name[number]"
     */
    printf(
        '<input name="%1$s[%2$s]" id="%3$s" value="%4$s" class="regular-text">',
        $args['option_name'],
        $args['name'],
        $args['label_for'],
        $args['value']
    );
    // t5_sae_debug_var( func_get_args(), __FUNCTION__ );
}
function t5_sae_render_section_1_field_2( $args )
{
    printf(
        '<select name="%1$s[%2$s]" id="%3$s">',
        $args['option_name'],
        $args['name'],
        $args['label_for']
    );

    foreach ( $args['options'] as $val => $title )
        printf(
            '<option value="%1$s" %2$s>%3$s</option>',
            $val,
            selected( $val, $args['value'], FALSE ),
            $title
        );

    print '</select>';

    // t5_sae_debug_var( func_get_args(), __FUNCTION__ );
}
function t5_sae_render_section_2()
{
    print '<p>Makes some notes.</p>';
}

function t5_sae_render_section_2_field_1( $args )
{
    printf(
        '<textarea name="%1$s[%2$s]" id="%3$s" rows="10" cols="30" class="code">%4$s</textarea>',
        $args['option_name'],
        $args['name'],
        $args['label_for'],
        $args['value']
    );
}

Oh, I introduced a function t5_sae_debug_var(). Here it is:

function t5_sae_debug_var( $var, $before="" )
{
    $export = esc_html( var_export( $var, TRUE ) );
    print "<pre>$before = $export</pre>";
}

Useful to see if we got what we expected.

Now, this works pretty well, we need just one thing:

Validate the option array

Because we used the bracket notation, our value is an array. We just have to walk through each element and validate it.

function t5_sae_validate_option( $values )
{
    $default_values = array (
        'number' => 500,
        'color'  => 'blue',
        'long'   => ''
    );

    if ( ! is_array( $values ) ) // some bogus data
        return $default_values;

    $out = array ();

    foreach ( $default_values as $key => $value )
    {
        if ( empty ( $values[ $key ] ) )
        {
            $out[ $key ] = $value;
        }
        else
        {
            if ( 'number' === $key )
            {
                if ( 0 > $values[ $key ] )
                    add_settings_error(
                        'plugin:t5_sae_option_group',
                        'number-too-low',
                        'Number must be between 1 and 1000.'
                    );
                elseif ( 1000 < $values[ $key ] )
                    add_settings_error(
                        'plugin:t5_sae_option_group',
                        'number-too-high',
                        'Number must be between 1 and 1000.'
                    );
                else
                    $out[ $key ] = $values[ $key ];
            }
            elseif ( 'long' === $key )
            {
                $out[ $key ] = trim( $values[ $key ] );
            }
            else
            {
                $out[ $key ] = $values[ $key ];
            }
        }
    }

    return $out;
}

This is rather ugly; I wouldn’t use such code in production. But it does what it should: it returns a validated array of values. WordPress will serialize the array, store it under our option name in the database and return it unserialized, when we call get_option().


All of this works, but it is unnecessary complicated, we get markup from 1998 (<tr valign="top">), and many redundancies.

Use the settings API when you have to. As an alternative use admin_url( 'admin-post.php' ) as form action (look at its source) and create the complete settings page with your own, probably more elegant code.

Actually, you have to do that when you write a network plugin, because the settings API doesn’t work there.

There are also some edge cases and incomplete parts I didn’t mention here – you will find them when you need them. 🙂

tech