Aryan Jasala

WordPress plugin fundamentals: headers, load order, hooks and options

A plugin dropping into a slot alongside WordPress core

A WordPress plugin is a folder with one header comment in it, and everything after that is hooks. This covers the header, where the file has to live, the order plugins load in, how actions and filters really behave, the functions for inspecting and removing them, the options API, and registering a custom post type.

Plugin load order
Must-use pluginsfiles in mu-plugins
Network-activatedmultisite only
active_pluginsalphabetical by directory name

active_plugins is a real array in wp_options, in folder/file.php form. If your plugin depends on another plugin's hooks having already run, that dependency is settled by the first letter of a directory name.

wp-settings.php loads plugins in three stages, and order within the last stage is alphabetical by directory name.

The header

The full set of fields:

/**
 * Plugin Name: My Basics Plugin
 * Plugin URI: https://example.com/plugins/the-basics/
 * Description: Handle the basics with this plugin.
 * Version: 1.10.3
 * Requires at least: 5.2
 * Requires PHP: 7.2
 * Author: John Smith
 * Author URI: https://author.example.com/
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Update URI: https://example.com/my-plugin/
 * Text Domain: my-basics-plugin
 * Domain Path: /languages
 */

The last two are what make the plugin translatable. Exactly one field is required:

/**
 * Plugin Name: Demo Plugin
 */

Save that in wp-content/plugins/demo-plugin/demo-plugin.php and it appears in the plugins table. That is the whole bar. ๐Ÿ™ƒ

Where the file has to live

Three rules, all of which produce confusing symptoms rather than errors:

  • name the main file after the folder, because that is the first place WordPress looks
  • the main file cannot sit in a nested folder. my-plugin/inc/main-file.php will not be found
  • only one file may carry the header, or you get one row in the plugins table per file that does

And do not edit core. A core file you changed is a change that disappears at the next update, silently, and probably on a Friday. ๐Ÿ“…

Load order

From wp-settings.php, in this sequence:

  • must-use plugins in mu-plugins
  • network-activated plugins, on multisite
  • everything in the active_plugins option, alphabetically

That last one is a real array in wp_options, in folder/file.php form:

array(
    'query-monitor/query-monitor.php',
    'movie-library/movie-library.php',
)

Alphabetical is worth sitting with. If your plugin depends on another’s hooks having run, your fate is decided by the first letter of a directory name. ๐Ÿคก

Actions and filters

Both run on hooks. The difference is the return value:

  • an action executes code and returns nothing. do_action()
  • a filter receives a value, changes it, and returns it. apply_filters()

Register an action and a filter on the same hook name and both callbacks fire, whichever function triggered it.

Apply a filter nobody registered and you get your value back unchanged, because apply_filters() checks whether the hook exists before doing anything. So a typo in a filter name is not an error, it is a filter that quietly does nothing forever. ๐Ÿ”‡

add_action() and add_filter() both return true, always, hardcoded. Checking the return value tells you nothing.

Inspecting hooks

Two globals hold the state:

  • $wp_filter -> every action and filter with its callbacks
  • $wp_actions -> how many times each action has fired

And the functions that read them:

  • did_action( $hook ) -> how many times it fired
  • has_action( $hook, $callback ) -> whether that specific callback is attached
  • has_filter( $hook, $callback ) -> the same for filters
  • doing_action( $hook ) and current_filter() -> what is executing right now, read off $wp_current_filter

current_filter() earns its place when one callback serves several hooks.

There is also the all hook, which fires on every action and filter in the request. Attach to it, call current_filter(), and you can watch the whole request go past. Debugging only. ๐Ÿ”ญ

Removing hooks

remove_action( $hook, $callback );
remove_all_actions( $hook, $priority );

remove_action() needs the hook and the callback, and the priority has to match what was used to add it. remove_all_actions() clears everything on the hook, or only one priority level if you pass one.

Options

add_option( $option, $value, $deprecated, $autoload );
get_option( $option, $default );
update_option( $option, $value, $autoload );
delete_option( $option );

get_option() returns $default when the option is missing, and false when you did not supply a default.

update_option() creates the option if it does not exist, so most code never needs add_option() at all.

The behaviour that catches people: update_option() returns false and writes nothing when the new value matches the old. Core compares raw values and their serialized forms to avoid a pointless query. So false means “no change”, not “failed”, and code that treats it as an error is wrong.

Autoload, and what changed since

Autoloaded options load on every request, so a large option marked autoload is a tax on the whole site. ๐Ÿงพ

The old advice was that you could not change autoload on its own, because update_option() refuses to act when the value is identical, so you had to change the value too. That is no longer true. Core added dedicated functions:

wp_set_option_autoload( $option, $autoload );
wp_set_option_autoload_values( array( $option => $autoload ) );

add_option() and update_option() now both default $autoload to null and let core decide, rather than forcing everything to autoload.

Custom post types

Nearly everything in WordPress is a post. The built-in types: post, page, attachment, revision, navigation menu item, custom CSS, and changeset.

Scaffold one rather than typing it:

wp scaffold post-type --prompt
wp scaffold taxonomy --prompt

Both generate registration code you then hook to init:

register_post_type( $post_type, $args );
register_taxonomy( $taxonomy, $object_type, $args );

The arguments that decide most of the behaviour:

'public'            => true,
'hierarchical'      => false,
'show_ui'           => true,
'show_in_nav_menus' => true,
'supports'          => array( 'title', 'editor', 'excerpt', 'thumbnail', 'author', 'comments' ),
'has_archive'       => true,
'rewrite'           => true,
'query_var'         => true,
'menu_icon'         => 'dashicons-video-alt2',

That rewrite argument is where permalinks and flushing come in, and it is the one most likely to make a working post type return a 404. Reading posts back out goes through WP_Query:

$query = new WP_Query( array( 'p' => 123 ) );

What this does not cover

Activation, deactivation and uninstall hooks in any depth, show_in_rest and its block editor implications, and capability mapping, which is where a custom post type stops being a five-minute job. โฑ๏ธ