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:
/**
* 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:
/**
* 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.phpis 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_loadedfires- everything in the
active_pluginsoption plugins_loadedfires
active_plugins is a real array in wp_options, in folder/file.php form:
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 )anddid_filter( $hook )-> the countshas_action( $hook, $callback )andhas_filter()-> the priority the callback is attached at, orfalse. The priority can be0, so compare withfalse !== .... Since 6.9 both take a third$priorityargument and then return a plain booleandoing_action( $hook )andcurrent_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
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
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:
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:
wp scaffold post-type movie --plugin=movie-library
wp scaffold taxonomy genre --post_types=movie --plugin=movie-libraryThe 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:
'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:
$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