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

What WordPress reads first, the order plugins load in, and how hooks and options really behave

Leave a comment
Risograph illustration of a wall rail of five hooks holding a key, a coiled rope and a tote bag, two hooks empty

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 behave, inspecting and removing hooks, the options API and its autoload rules, and registering a custom post type.

The header

The full set of fields:

my-basics-plugin/my-basics-plugin.phpphp
/**
 * 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: 6.5
 * Requires PHP:      7.4
 * 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
 * Requires Plugins:  woocommerce
 */

Text Domain and Domain Path tie it to its translations. Requires Plugins, new in 6.5, takes WordPress.org slugs and blocks activation until those plugins are active.

Exactly one field is required:

php
/**
 * 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

get_plugins() reads every .php file at the top of wp-content/plugins/ and one level into each folder, and any file with a Plugin Name header counts. That gives three rules:

  • the main file sits directly in the plugin folder. my-plugin/inc/main-file.php is one level too deep and will not be found
  • only one file may carry the header, or the plugins table gets one row per file that does
  • naming the main file after the folder is a convention, not a lookup rule. The 2022 notes said WordPress looks there first. It does not, it reads them all

And do not edit core. A core file we 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
  • muplugins_loaded fires
  • everything in the active_plugins option
  • plugins_loaded fires

active_plugins is a real array in wp_options, in folder/file.php form:

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

activate_plugin() runs sort() on that array every time a plugin is activated, so the order is alphabetical by folder name. If our plugin depends on another’s code having loaded, our fate is decided by the first letter of a directory name. Requires Plugins does not change that: the 6.5 dev note says plugin dependencies do not account for load order. So reach for another plugin’s code on plugins_loaded or later, never at file load.

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()

add_action() is a wrapper that calls add_filter(), so both live in the same registry. 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 we get the value back unchanged. 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 end in a hardcoded return true;. Checking the return value tells us nothing.

Inspecting hooks

The globals that hold the state:

  • $wp_filter -> every hook with its callbacks
  • $wp_actions -> how many times each action has fired
  • $wp_filters -> how many times each filter has been applied, since 6.1

And the functions that read them:

  • did_action( $hook ) and did_filter( $hook ) -> the counts
  • has_action( $hook, $callback ) and has_filter() -> the priority the callback is attached at, or false. The priority can be 0, so compare with false !== .... Since 6.9 both take a third $priority argument and then return a plain boolean
  • 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 we can watch the whole request go past. Debugging only.

Removing hooks

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

remove_action() needs the hook, the callback, and the same priority it was added with. The default is 10, so a callback added at 20 survives a two-argument call. remove_all_actions() clears everything on the hook, or only one priority level if we pass one.

The options API

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

get_option() returns the default when the option is missing, and false when no default was supplied. 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 both the raw values and their serialized forms first. So false means “no change”, not “failed”, and code that treats it as an error is wrong.

Autoload, and what changed

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

The 2022 advice was that autoload could not change on its own, because update_option() refuses to act on an identical value. Since 6.4 there are dedicated functions:

php
wp_set_option_autoload( $option, $autoload );
wp_set_options_autoload( array( $option_a, $option_b ), $autoload );
wp_set_option_autoload_values( array( $option => $autoload ) );

Since 6.6, $autoload defaults to null on both add_option() and update_option(), and core decides. In practice null still autoloads, unless the serialized value is over 150,000 bytes, a threshold the wp_max_autoloaded_option_size filter can move. Since 6.7 the old 'yes' and 'no' values are deprecated.

Custom post types

Nearly everything in WordPress is a post. The 2022 notes listed seven built-in post types. Core registers 16 now, the additions being mostly block theme internals: wp_block, wp_template, wp_template_part, wp_global_styles, wp_navigation, wp_font_family, wp_font_face, plus oembed_cache and user_request. 16 - 7 => 9.

Scaffold one rather than typing it:

shell · bash
wp scaffold post-type movie --plugin=movie-library
wp scaffold taxonomy genre --post_types=movie --plugin=movie-library

The generated file already hooks the registration to init and adds the updated-message filters. --raw gives only the bare register_post_type() call. The arguments that decide most of the behaviour:

php
'public'            => true,
'hierarchical'      => false,
'show_ui'           => true,
'show_in_rest'      => 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',

show_in_rest defaults to false in core, and without it the post type gets the classic editor. The scaffold sets it to true. The 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 a post back goes through WP_Query, and it needs the post type. Without one, the query adds post_type = 'post' and finds nothing:

php
$query = new WP_Query( array( 'post_type' => 'movie', 'p' => 123 ) );

What this does not cover

Activation, deactivation and uninstall hooks in any depth, the block editor side of show_in_rest, and capability mapping, which is where a custom post type stops being a five-minute job.

Leave a comment

Seen this one too, or think I got something wrong? Your email is not published, and a first comment waits for me to approve it.