Creating a Menu for your Plugin [WordPress Development – Part 2]

This is the second part of my WordPress Plugin Development tutorial. Check also the first part.

1. Adding a menu item:

A menu item is created by calling the add_options_page() method, which should be called inside a admin_menu action.

It’s a 3-step process: listen action, call add_options_page(), define HTML page.

This adds a menu item in the “Settings” menu. For other menus:

  • Dashboard: add_dashboard_page()
  • Posts: add_posts_page()
  • Media: add_media_page()
  • Pages: add_pages_page()
  • etc

They all have the same syntax as the Settings menu.

Now that you know the basics of menu creation, let’s see how to create a settings page. But don’t worry, we are going to see advanced menu topics later (custom menus, icons, sub-menus).

I recommend to use a more restrictive permission than the generic manage_options. You can find a list of all permissions here:

2. Admin page HTML:

So far we have created this simple HTML page for our menu on my_plugin_options():

Let’s now replace it with a form that saves data on the database:

  • settings_fields( $option_group ): Creates some hidden fields:
  • do_settings_sections( $option_group ): Renders all the HTML inputs. We are going to define this next.
  • submit_button(): Renders the submit button.


Each group option is a row on the wp_options table. While is possible to save each setting as a row, this clutters the table. Instead, we group options and save them together in just one row. The $option_group value is the id of the row.

You can use more than one option group in your plugin - just try to not use many of them, one per menu item is a good choice.


Fields are visually grouped in sections. Let's define some:

The result:

The first argument is the section id; the second argument is the title; the third argument is the callback; the fourth is the option group.


Now, let's define some fields for each section:


If you click on save, an "ERROR: options page not found" will happen:

To fix this, we need to register our fields with register_setting():

And let's show the success message with settings_errors():

Now, click to save it again and voila:

Retrieving data:

Data is being saved, but not being showed. Let's fix it with get_option( $field ):

As result, it now shows the current values:

Escape the output:

It is a good practice to strip invalid or special characters before output. For this, use the esc_attr( $text ) function.

esc_attr( get_option( 'field1' ) )

4. Validation and Sanitization:

Let's say our first field is an email and we want to validate it.

The 3rd argument of register_setting() is the sanitization callback. The callback should return the value that will be saved:

register_setting( 'my-menu', 'field1', [ $this, 'validation_callback1' ] );

public function validation_callback1( $input ) {
  return sanitize_email( $input );

Now, let's throw an error message with add_settings_error( string $setting, string $code, string $message, string $type = 'error' ):

public function validation_callback1( $input ) {
  /** Sanitize input */
  $sanitized = sanitize_email( $input );

  /** If output is different, input was wrong */
  if ( $sanitized !== $input ) {
    add_settings_error( 'field1', 'your-error-code', 'Field 1 is not valid!' );

  /** Save the sanitized version */
  return $sanitized;

5. Permissions:

It is a good practice to check if the user has permissions to access the page. You can use the is_admin() or current_user_can( $capability ) methods for this:

You can find a list of all permissions here:

6. Drying the code:

Defining setting fields can be a repetitive task. And it doesn't help that each input requires its own method.

So, let's refactor our example and DRY (don't-repeat-yourself) our code.

First, let's use a generic method for the field rendering:


The add_settings_field() method accepts an extra argument that is sent to the callback:

add_settings_field( $id, $title, $callback, $page, $section, $args )

Let's pass in this argument all the information necessary to build the HTML element:

Now, let's refactor the field_callback() to generate the field with this information:

Final code:

Finally, let's structure the sections/fields in an array and automate the process:

7. Custom menu:

For a custom menu, use the add_menu_page() method instead:

/** Step 2 (add item). */
public function my_plugin_menu() {
  $page_title = 'My Plugin Options';
  $menu_title = 'My Plugin';
  $capability = 'manage_options'; // which menu.
  $menu_slug  = 'my-menu'; // unique identifier.
  $callback   = [ $this, 'my_plugin_options' ];
  add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback );

This function has two extra optional arguments: icon and position:

Custom icon:

$icon       = 'dashicons-media-code';
add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback, $icon );

The complete list of icons:

For a custom icon, use: plugins_url( 'my-plugin/icon.png' ). You can also pass a base64-encoded SVG. Alternatively, pass 'none' and set your icon on the CSS.

Custom position:

The last argument, also optional, is the menu position. By default, it will be placed at the bottom, but you can change this.

Position values:

  • 2 – Dashboard
  • 4 – Separator
  • 5 – Posts
  • 10 – Media
  • 15 – Links
  • 20 – Pages
  • 25 – Comments
  • 59 – Separator
  • 60 – Appearance
  • 65 – Plugins
  • 70 – Users
  • 75 – Tools
  • 80 – Settings
  • 99 – Separator

For example, position 76 will place it between Tools and Settings.

$position   = 76;
add_menu_page( $page_title, $menu_title, $capability, $menu_slug, $callback, $icon, $position );

8. Sub-menus:

To add sub-menus, use the add_submenu_page( ... ) method.

The first argument is the parent menu slug. The other arguments are the same as the other methods:


Notice that the first item has the same title as the menu. That's the default behavior on WordPress.

Setting a different title for the first sub-menu item:

To fix it, add an item with the same slug as the parent menu.

9. Communicating with the server:

Let's say you want to have another button, besides the standard "Save" button. For example, let's create a "Hello World" button, that echoes a "Hello World" on screen.

For a non-Ajax solution, you should use the dynamic hook admin_post_{$plugin_action}.

For Ajax, you can use Ajax API, REST API, admin_post. Learn more about the first two on the next part of this tutorial.

Alternatively, you can implement a "router" (Ajax and non-Ajax), which basically consists of intercept every single HTTP request and call our custom method if URL and parameters match our route.

9.1. admin_post:

Add this code to the constructor:

This calls hello_callback() on an authenticated POST /wp-admin/admin-post.php?action=myplugin_hello requests.

Note 1: In order to avoid conflict with other plugins, add your slug to the action name. In other words, prefer "myplugin_hello" instead of "hello".

Note 2: Implement this in the __construct() constructor. It won't work if you implement it on "admin_menu", "load-{$menu}, or any other specific action.

Then, implement the button and its form:

And the callback:

9.2. Adding security - Nonces and permissions:

"admin_post" automatically checks if the user is authenticated on the admin dashboard and nothing else. It is recommended to also check for intention (nonce):

Alternatively, you can use settings_fields( $action ) to generate the _wpnonce input.

Normal users can also access the admin dashboard, so you MUST check for permissions - that's a critical item:

I recommend to use a more restrictive permission than the generic manage_options. Check the best permission for you here:

9.3. Replacing the "Save" button with your custom one:

If you want to have a single button that saves and also does other action, you can follow these instructions.

First, set the constructor (as before):

Adapt the default form: set "admin-post.php" as the form URL, pass the "action" as a hidden input, change the button name (optional):

On the callback, you must manually save the inputs:

10. The Router method (an alternative to "admin_post"):

The idea here is to intercept all HTTP requests (by hooking on the init action) and filter the ones directed to your endpoint.

In this example, let's create the "Hello World" button using a router.

The URL will be /wp-admin/admin.php?page=my-plugin&action=hello-world. But it can be anything (ex. "/hello/world", if the installation supports pretty URLs):

First, let's add a callback called on every admin request:

You can use this method for non-admin calls, just replace admin_init with init.

Then, let's filter the requests that are directed to us (on the callback):

Now, let's replace the standard "Save" button with our "Hello" button:

If you want to save the options, call update_option( $field, $value ):

Additionally, you can filter by the HTTP verb (GET, POST, etc) in the callback to avoid conflicts.

You don't need to replace the "Save" button. You can have both. If you decide to keep the save button, just be aware that you can't have nested "<form>" tags. In this case, add the other forms next to the main form (not inside). Then, your custom submit button can be inside the main form, outside its own form, but it should have the form=my-other-form attribute.

Now, let's add security by checking permissions and nonces:

Then, generate the nonce (pass it either in the URL or as a hidden input):

Close Menu