Hooks: Actions and Filters
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
Hooks are the extension mechanism of WordPress: core calls do_action() and apply_filters() at hundreds of named
points, and plugins and themes attach callbacks to those names. This page goes beyond the short introduction in
Introduction and Architecture:
how callbacks are ordered and how many arguments they receive, why a removal silently fails, how the lumen-catalog
plugin offers its own lumen_catalog_* hooks, how code tells which hook is running, the order in which core fires its
actions on front-end, admin and REST requests, and the older pluggable functions mechanism.
The page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WP-CLI 2.12.0, and checked on 2026-10-28.
WordPress 7.2 is in beta and due in December 2026; it does not change the Plugin API
so far. Two recent versions did: 6.9 added a $priority parameter to has_filter() and has_action(), and 7.1
changed how object and closure callbacks are identified internally. Every output below comes from a 7.1.3 site at
lumenpress.test running Twenty Twenty-Five and the lumen-catalog plugin, on the stack described in
Local Development Environments. For readability the examples
are short procedural snippets with prefixed names (Lumen_Catalog_Stock, lumen_catalog_import_book()); the plugin
itself keeps its code under the Lumen\Catalog namespace, as shown in
Plugin Fundamentals.
Actions and filters
Both kinds of hook share one registry and one set of functions; they differ in what is expected of the callback.
| Aspect | Action | Filter |
|---|---|---|
Purpose |
Run code at a point of the lifecycle |
Change a value before core or a plugin uses it |
Fired with |
|
|
Attached with |
|
|
Callback’s return value |
Ignored |
Input of the next callback, then the result of |
Side effects |
Expected: register, save, send, print |
To be avoided: no output, no globals, no database writes |
Examples |
|
|
Counted by |
|
|
Internally add_action() just calls add_filter(). Each hook name with callbacks owns a WP_Hook object (4.7+) in the
global $wp_filter, which groups the callbacks by priority; $wp_current_filter is the stack of hooks running now.
"No side effects in filters" is a design rule that WordPress does not enforce, and it matters because filters run more
often than they seem: the_title for every menu item and REST response, the_content for excerpts and feeds too.
Registering callbacks
add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): true
add_filter( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): true
Source: Code Reference: add_filter()
Any PHP callable works, and its form decides how it can be removed later:
| Form | Example | Removable with |
|---|---|---|
Function name |
|
The same string |
Static method |
|
The same class and method |
Object method |
|
The same instance and method |
Closure or arrow function |
|
Only the same closure object, kept in a variable |
Invokable object |
an instance of a class with |
The same instance |
Namespaced functions need their full name, 'Lumen\Catalog\register_book_type' or
__NAMESPACE__ . '\register_book_type'. A bare name is looked up in the global namespace when the hook fires, and
call_user_func_array() fails with an "invalid callback" error.
Priority
Lower priorities run first, equal ones in the order they were added; the default is 10, and negative values,
PHP_INT_MIN and PHP_INT_MAX are valid. A plugin filtering the_content chooses its place among core’s callbacks:
| Priority | Core callbacks on the_content in 7.1.3 |
|---|---|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
20 |
|
A plugin callback at 10 runs after core’s callbacks at 10 (added earlier) but before do_shortcode, so shortcodes in
its markup are expanded; one before 9 sees raw block comments. The Lumen Press details box is appended at 20. A callback added to a hook while it runs is honoured if its
priority has not been reached yet, because WP_Hook re-sorts the active iteration (4.7+).
Accepted arguments
WP_Hook passes the first $accepted_args arguments of the hook, all of them if the number is larger, none if it is 0.
The default of 1 suits filters that need only the value, but not callbacks that need the context: the_title passes the
title and the post ID, save_post the ID, the post and an update flag. The details box below is a filter at priority
20 that also fires a filter of its own, with two arguments, so that other code can add rows:
<?php
defined( 'ABSPATH' ) || exit;
// Priority 20: after do_blocks (9), wpautop (10), do_shortcode (11) and wp_filter_content_tags (12).
add_filter( 'the_content', 'lumen_catalog_append_book_details', 20 );
/**
* Appends the details box to a book's content in the main loop.
*
* @param string $content Post content, already rendered by earlier callbacks.
* @return string
*/
function lumen_catalog_append_book_details( $content ) {
if ( ! is_singular( 'lp_book' ) || ! in_the_loop() || ! is_main_query() ) {
return $content; // A filter always returns a value, even when it changes nothing.
}
$post_id = get_the_ID();
$rows = array(
__( 'ISBN', 'lumen-catalog' ) => get_post_meta( $post_id, 'lp_isbn', true ),
__( 'Pages', 'lumen-catalog' ) => get_post_meta( $post_id, 'lp_pages', true ),
);
/**
* Filters the rows of the book details box.
*
* @since 0.1.0
* @param array<string,string> $rows Label => value pairs. Empty values are skipped.
* @param int $post_id Book ID.
*/
$rows = apply_filters( 'lumen_catalog_book_details_rows', $rows, $post_id );
$html = '';
foreach ( $rows as $label => $value ) {
if ( '' !== (string) $value ) {
$html .= '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';
}
}
return $content . '<dl class="lp-book-details">' . $html . '</dl>';
}
Source: Plugin Handbook: Filters
The main plugin file loads it with require_once __DIR__ . '/includes/book-details.php';. The checks on
is_singular(), in_the_loop() and is_main_query() keep the box out of excerpts, feeds, Query Loop blocks and the
REST API, which all apply the_content too. To see what is attached to a hook, read
$GLOBALS['wp_filter']['the_title']->callbacks: an array keyed by priority, then by callback identifier.
Two mistakes are not caught when the callback is added. A callback that requires more parameters than $accepted_args
provides throws an ArgumentCountError when the hook fires, a fatal error on a real request (on the test site, "Too
few arguments to function lumen_demo_title(), 1 passed in class-wp-hook.php on line 355 and exactly 2 expected"). A
filter that does not return hands null on, and get_the_title() then returns NULL.
Removing callbacks
remove_action() and remove_filter() return whether a callback was removed. They fail silently, returning false,
unless everything matches the original registration:
-
The same hook name and priority. A callback added at 20 needs
remove_filter( $hook, $callback, 20 ). -
The same callback. WordPress compares an identifier built by the internal
_wp_filter_build_unique_id(): the name for a function,Class::methodfor a static method, an instance ID for objects and closures (on the test site1961mark_out_of_stockfor the stock badge below). Since 7.1 that ID comes fromspl_object_id()instead ofspl_object_hash(), and since 7.1.1 a closure’s ID has anspl_object_id:prefix so PHP never makes it an integer array key. Another instance of the same class, or a closure with the same code, is a different callback. -
The right moment: after the callback is added and before the hook fires.
The catalogue’s stock badge (the lp_stock meta comes from the distributor sync in
WP-Cron and Scheduled Tasks) is attached from an object:
<?php
defined( 'ABSPATH' ) || exit;
/**
* Stock badge in book titles. Its hooks are attached from an object, so removing them needs the same instance.
*/
final class Lumen_Catalog_Stock {
/** @var Lumen_Catalog_Stock|null The instance that owns the hooks. */
private static $instance = null;
/**
* Returns the instance, creating it and attaching its callbacks on first use.
*/
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
add_filter( 'the_title', array( self::$instance, 'mark_out_of_stock' ), 10, 2 );
}
return self::$instance;
}
/**
* Appends "(out of stock)" to the title of books whose stock is zero.
*
* @param string $title Post title.
* @param int $post_id Post ID, the second argument of the_title.
* @return string
*/
public function mark_out_of_stock( $title, $post_id = 0 ) {
if ( 'lp_book' === get_post_type( $post_id ) && '0' === get_post_meta( $post_id, 'lp_stock', true ) ) {
/* translators: %s: book title. */
$title = sprintf( __( '%s (out of stock)', 'lumen-catalog' ), $title );
}
return $title;
}
}
add_action( 'plugins_loaded', array( 'Lumen_Catalog_Stock', 'instance' ) );
Source: Plugin Handbook: Advanced Topics
The default value of $post_id keeps the callback working where the_title is applied with one argument. The static
accessor makes the callback removable: a plugin that attaches array( new Something(), 'method' ) and
keeps no reference leaves only remove_all_filters(), which also removes core’s and every other plugin’s callbacks.
This script checks each rule; the comments show what wp eval-file printed:
<?php
// Run with: wp eval-file hook-removal-demo.php
add_filter( 'lumen_demo', 'strtoupper', 20 );
var_dump( remove_filter( 'lumen_demo', 'strtoupper' ) ); // bool(false): wrong priority (default 10).
var_dump( remove_filter( 'lumen_demo', 'strtoupper', 20 ) ); // bool(true): same name and priority.
$exclaim = static fn( $text ) => $text . '!';
add_filter( 'lumen_demo', $exclaim );
var_dump( remove_filter( 'lumen_demo', static fn( $text ) => $text . '!' ) ); // bool(false): look-alike closure.
var_dump( remove_filter( 'lumen_demo', $exclaim ) ); // bool(true): the same closure.
$stock = Lumen_Catalog_Stock::instance();
var_dump( has_filter( 'the_title', array( $stock, 'mark_out_of_stock' ) ) ); // int(10): its priority.
var_dump( remove_filter( 'the_title', array( new Lumen_Catalog_Stock(), 'mark_out_of_stock' ) ) ); // bool(false): other instance.
var_dump( remove_filter( 'the_title', array( $stock, 'mark_out_of_stock' ) ) ); // bool(true): same instance.
Lumen Press removes the badge from a must-use plugin, which loads before the catalogue. The removal therefore waits:
add_action( 'plugins_loaded', static fn() => remove_filter( 'the_title', array( Lumen_Catalog_Stock::instance(), 'mark_out_of_stock' ) ), 20 ).
Core’s generator tag, attached in default-filters.php before any plugin loads, can be removed at once with
remove_action( 'wp_head', 'wp_generator' ). With lp_stock set to 0, the book heading changes from
The Quiet Lighthouse (out of stock) to The Quiet Lighthouse, and the <meta name="generator"> tag disappears. If
the catalogue can be inactive, guard the removal with class_exists( 'Lumen_Catalog_Stock' ).
Custom hooks
Hooks fired by a plugin let other code change it without editing it, so lumen-catalog stays generic. The rules:
-
Prefix every hook name (
lumen_catalog_). Two plugins firing an unprefixedbook_importedrun each other’s code. -
Lowercase words separated by underscores; WPCS reports other names (
WordPress.NamingConventions.ValidHookName). -
An action where others react, a filter where others decide, and pass the context callbacks need after the value.
-
Document each hook with a docblock right above the call: summary,
@since, one@paramper argument. -
Treat hooks as public API. Renaming one or changing its arguments breaks other people’s code; deprecate instead.
The import function behind wp lumen import fires a general filter, a filter whose name contains the source, and an
action once the book exists:
<?php
defined( 'ABSPATH' ) || exit;
/**
* Creates a book from an import row.
*
* @param array $row Keys: title, isbn, pages.
* @param string $source Where the row comes from, for example 'csv' or 'distributor'.
* @return int|WP_Error Post ID, or an error from wp_insert_post().
*/
function lumen_catalog_import_book( array $row, $source ) {
/**
* Filters an import row from any source.
*
* @since 0.1.0
* @param array $row Import row.
* @param string $source Import source.
*/
$row = apply_filters( 'lumen_catalog_import_row', $row, $source );
/**
* Filters an import row from one source only. The dynamic part, `$source`, is the import source.
*
* @since 0.1.0
* @param array $row Import row.
*/
$row = apply_filters( "lumen_catalog_import_row_{$source}", $row );
$post_id = wp_insert_post(
array(
'post_type' => 'lp_book',
'post_status' => 'publish',
'post_title' => sanitize_text_field( $row['title'] ?? '' ),
'meta_input' => array(
'lp_isbn' => sanitize_text_field( $row['isbn'] ?? '' ),
'lp_pages' => absint( $row['pages'] ?? 0 ),
),
),
true
);
if ( ! is_wp_error( $post_id ) ) {
/**
* Fires after a book has been imported.
*
* @since 0.1.0
* @param int $post_id New book ID.
* @param array $row Import row, after the filters.
* @param string $source Import source.
*/
do_action( 'lumen_catalog_book_imported', $post_id, $row, $source );
}
return $post_id;
}
The site’s must-use plugin uses all three, each with the number of accepted arguments it needs:
// The distributor sends ISBNs without hyphens and titles in capitals; only its rows are touched.
add_filter(
'lumen_catalog_import_row_distributor',
static function ( array $row ) {
$row['isbn'] = preg_replace( '/[^0-9X]/', '', strtoupper( $row['isbn'] ?? '' ) );
$row['title'] = ucwords( strtolower( $row['title'] ?? '' ) );
return $row;
}
);
// Add a row to the details box; 2 accepted arguments because the callback needs the book ID.
add_filter(
'lumen_catalog_book_details_rows',
static function ( array $rows, $post_id ) {
$series = get_the_term_list( $post_id, 'lp_series', '', ', ' );
if ( is_string( $series ) && '' !== $series ) {
$rows[ __( 'Series', 'lumen-catalog' ) ] = wp_strip_all_tags( $series );
}
return $rows;
},
10,
2
);
// React to an import: 3 accepted arguments; the return value of an action is ignored.
add_action(
'lumen_catalog_book_imported',
static function ( $post_id, array $row, $source ) {
error_log( sprintf( 'Imported book %d (%s) from %s', $post_id, $row['isbn'] ?? '', $source ) );
},
10,
3
);
wp eval '
$id = lumen_catalog_import_book( array( "title" => "WINTER ORCHARD", "isbn" => "978-0-306-40615-7", "pages" => "248" ), "distributor" );
echo get_the_title( $id ), " | ", get_post_meta( $id, "lp_isbn", true ), PHP_EOL;
$id = lumen_catalog_import_book( array( "title" => "Harbour Lights", "isbn" => "978-1-56619-909-4" ), "csv" );
echo get_the_title( $id ), " | ", get_post_meta( $id, "lp_isbn", true ), PHP_EOL;
echo did_action( "lumen_catalog_book_imported" ), " imports, filter applied ", did_filter( "lumen_catalog_import_row" ), " times", PHP_EOL;
'
Source: Code Reference: did_filter()
Winter Orchard | 9780306406157
Harbour Lights | 978-1-56619-909-4
2 imports, filter applied 2 times
The debug log receives one Imported book <ID> (<ISBN>) from <source> line per book, and the details box
of a book in the Coastal Trilogy series gains a third row, <dt>Series</dt><dd>Coastal Trilogy</dd>.
do_action_ref_array() and apply_filters_ref_array() take the arguments as one array; core uses them for hooks such
as pre_get_posts, which hands over the WP_Query object. To rename or retire a hook, apply_filters_deprecated() and
do_action_deprecated() (4.6+) take the old name, the arguments as an array, the version and the replacement. They
fire the old hook and, only when something is attached to it, raise a deprecation notice; the callback still runs:
apply_filters_deprecated( 'lumen_catalog_details', array( $rows, $post_id ), '0.1.0', 'lumen_catalog_book_details_rows' )
logs "Hook lumen_catalog_details is deprecated since version 0.1.0! Use lumen_catalog_book_details_rows instead."
Dynamic hook names
Many hooks contain a variable part, so a callback can target one post type, option or screen. The Code Reference
writes the variable in braces and replaces braces and $ with hyphens in the URL
(save_post_{$post->post_type} lives at /reference/hooks/save_post_post-post_type/).
| Pattern | Lumen Press instance | Fires |
|---|---|---|
|
|
After a book is saved, before the generic |
|
|
When a book enters the |
|
|
Before the REST API returns a book |
|
|
Building the columns of the book list |
|
|
Reading or updating one option |
|
|
Before an admin screen is rendered |
|
|
The plugin’s own dynamic hook |
save_post_lp_book fires inside wp_insert_post(), so a callback that calls wp_update_post() on the same book
triggers itself again. Detaching the callback around the update prevents the loop:
<?php
defined( 'ABSPATH' ) || exit;
add_action( 'save_post_lp_book', 'lumen_catalog_default_excerpt', 10, 3 );
/**
* Fills an empty excerpt from the first words of the content when a book is saved.
*
* @param int $post_id Book ID.
* @param WP_Post $post Book object.
* @param bool $update Whether an existing book was updated.
*/
function lumen_catalog_default_excerpt( $post_id, $post, $update ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || '' !== $post->post_excerpt ) {
return;
}
// wp_update_post() fires save_post_lp_book again: detach first, or the callback calls itself forever.
remove_action( 'save_post_lp_book', 'lumen_catalog_default_excerpt', 10 );
wp_update_post(
array(
'ID' => $post_id,
'post_excerpt' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 20 ),
)
);
add_action( 'save_post_lp_book', 'lumen_catalog_default_excerpt', 10, 3 );
}
id=$(wp post create --post_type=lp_book --post_title="Tide Tables" --post_status=publish --porcelain \
--post_content="Two sisters keep the tide tables of a small harbour town and discover that the sea is keeping a record of its own.")
wp post get "$id" --field=post_excerpt
Source: WP-CLI Commands: wp post create
The second command prints the first 20 words followed by …. update_post_meta() does not fire save_post,
so meta-only callbacks need no guard; code needing the final post and meta after a block editor save (which may send
meta in a second REST request) uses wp_after_insert_post (5.6+).
Inspecting hooks at run time
| Function | Answers |
|---|---|
|
How many times the hook fired in this request: the usual "too early?" and "only once" guard |
|
Whether that hook is running now, anywhere in the stack; without an argument, whether any hook is. Both read the same stack: |
|
The innermost hook running now; one callback attached to several hooks can branch on it |
|
Whether a hook has callbacks, or the priority of one callback (which can be |
The |
Callbacks that run before every action and filter, receiving the hook name first |
Inside the import, from WP-CLI:
wp eval '
add_filter( "lumen_catalog_import_row", function ( $row ) { echo current_filter(), " ", var_export( doing_filter( "lumen_catalog_import_row" ), true ), PHP_EOL; return $row; } );
add_action( "lumen_catalog_book_imported", function () { echo current_filter(), " ", var_export( doing_filter( "lumen_catalog_import_row" ), true ), " ", var_export( doing_action(), true ), PHP_EOL; } );
lumen_catalog_import_book( array( "title" => "Salt Roads" ), "csv" );
echo did_action( "init" ), " ", did_action( "wp" ), " ", var_export( doing_filter(), true ), PHP_EOL;'
lumen_catalog_import_row true
lumen_catalog_book_imported false true
1 0 false
In the action, the import filter is no longer on the stack (false), while an action is (true). After the import
nothing runs. did_action( 'wp' ) is 0 because WP-CLI never runs wp(): a reliable way to tell a command-line process
from a front-end request at the same point of the bootstrap.
The order in which core fires its hooks
The order explains most "my code does not run" reports: a callback added after its hook has fired never runs, and data
exists only from the hook that creates it. It can be recorded with a must-use plugin on the all hook that logs each
action once, at its first firing, and also the filters matching an optional regular expression:
<?php
/**
* Plugin Name: Lumen Hook Log
* Description: Logs the first firing of each action (and of matching filters) in one request. Local only.
*/
defined( 'ABSPATH' ) || exit;
// LUMEN_HOOK_LOG: true logs actions; a regular expression also logs matching filters, shown in [brackets].
if ( 'local' !== wp_get_environment_type() || ! defined( 'LUMEN_HOOK_LOG' ) || ! LUMEN_HOOK_LOG ) {
return;
}
$GLOBALS['lumen_hook_log'] = array();
add_action(
'all',
static function ( $hook_name ) {
static $actions_seen = array();
$log = &$GLOBALS['lumen_hook_log'];
// do_action() counts the hook in $wp_actions before it calls 'all'; apply_filters() does not.
$count = did_action( $hook_name );
$is_action = $count > ( $actions_seen[ $hook_name ] ?? 0 );
$actions_seen[ $hook_name ] = $count;
if ( $is_action ) {
$log[ $hook_name ] = $log[ $hook_name ] ?? $hook_name;
} elseif ( is_string( LUMEN_HOOK_LOG ) && preg_match( LUMEN_HOOK_LOG, $hook_name ) ) {
$log[ $hook_name ] = $log[ $hook_name ] ?? "[{$hook_name}]";
}
}
);
add_action(
'shutdown',
static function () {
$uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
error_log( "Hooks for {$uri}:" . PHP_EOL . wordwrap( implode( ' ', $GLOBALS['lumen_hook_log'] ), 116 ) );
},
PHP_INT_MAX
);
It needs WP_ENVIRONMENT_TYPE set to local, WP_DEBUG_LOG set to true and the constant itself, for example
wp config set LUMEN_HOOK_LOG true --raw. It adds a function call to every hook firing, thousands per request, so it
never belongs on a shared site.
Front-end request
A second (warm) request for /books/the-quiet-lighthouse/ logs 76 actions:
[28-Oct-2026 14:34:38 UTC] Hooks for /books/the-quiet-lighthouse/:
mu_plugin_loaded muplugins_loaded registered_taxonomy registered_taxonomy_category registered_taxonomy_post_tag
registered_taxonomy_nav_menu registered_taxonomy_link_category registered_taxonomy_post_format
registered_taxonomy_wp_theme registered_taxonomy_wp_template_part_area registered_taxonomy_wp_pattern_category
registered_post_type registered_post_type_post registered_post_type_page registered_post_type_attachment
registered_post_type_revision registered_post_type_nav_menu_item registered_post_type_custom_css
registered_post_type_customize_changeset registered_post_type_oembed_cache registered_post_type_user_request
registered_post_type_wp_block registered_post_type_wp_template registered_post_type_wp_template_part
registered_post_type_wp_global_styles registered_post_type_wp_navigation registered_post_type_wp_font_family
registered_post_type_wp_font_face plugin_loaded plugins_loaded sanitize_comment_cookies wp_roles_init setup_theme
unload_textdomain load_textdomain after_setup_theme auth_cookie_malformed set_current_user init widgets_init
wp_register_sidebar_widget wp_default_styles wp_sitemaps_init registered_post_type_lp_book
registered_taxonomy_lp_series wp_connectors_init wp_loaded parse_request parse_query pre_get_posts posts_selection
wp_cache_set_last_changed parse_term_query pre_get_terms metadata_lazyloader_queued_objects send_headers wp
template_redirect parse_tax_query wp_before_include_template loop_start the_post wp_default_scripts loop_no_results
render_block_core_template_part_file loop_end wp_head wp_enqueue_scripts enqueue_block_assets wp_print_styles
wp_print_scripts wp_body_open wp_footer wp_load_speculation_rules wp_print_footer_scripts shutdown
What the trace adds to the overview in the introduction:
-
Core fires
widgets_initfrominitat priority 1, after its own post types and taxonomies (0), andwp_connectors_init(the AI Connectors registry, 7.0) at 15; the plugin’slp_bookandlp_series, registered oninitat 10, fall between them. -
The current user is resolved on first use: here just before
init, on the admin request below beforeafter_setup_theme. Capability checks belong oninitor later. -
template-canvas.phprenders the whole block template before callingwp_head(), so in a block theme the loop hooks fire beforewp_headandget_header,get_footerandget_sidebarnever fire. Blocks can thus enqueue assets while rendering and still have them printed in the head. A classic theme callsget_header()first. -
wp_enqueue_scriptsis fired by a callback onwp_headat priority 1; enqueueing onwp_headis too late. -
The first request after installation also fired
save_postandpublish_wp_navigationwhile rendering, because the Navigation block created its fallbackwp_navigationmenu. Cold traces include such one-off work.
Admin request
A logged-in request for the book list (/wp-admin/edit.php?post_type=lp_book) loads the same bootstrap, with the user
authenticated early (auth_cookie_valid, set_current_user before after_setup_theme), and then wp-admin/admin.php
takes over. After wp_loaded the log continues with auth_redirect, _admin_menu, admin_menu, admin_init,
admin_bar_init, current_screen, load-edit.php, parse_request to wp, admin_enqueue_scripts,
admin_print_styles-edit.php, admin_print_styles, admin_print_scripts, admin_head, in_admin_header,
admin_notices, all_admin_notices, restrict_manage_posts, the_post (one per row), in_admin_footer,
admin_footer, admin_print_footer_scripts and shutdown.
-
admin_menufires beforeadmin_init, becauseadmin.phpbuilds the menu first: menu pages are added onadmin_menu, settings onadmin_init(see Administration Menus and Settings API).admin_initalso fires inadmin-ajax.phpandadmin-post.php. -
current_screenandload-{$pagenow}are the first points where the screen is known (help tabs, handling a form posted to the same screen).admin_enqueue_scriptsreceives the hook suffix (edit.php), so assets can be limited to the plugin’s own screens. -
List screens call
wp(), soparse_request,pre_get_postsandwpfire in the admin too; a front-endpre_get_postscallback checks! is_admin()and$query->is_main_query().
REST request
A REST request goes through index.php and wp(), but rest_api_loaded(), attached to parse_request, serves the
response and exits. With LUMEN_HOOK_LOG set to '/^rest_(?!.*(_item_schema|_collection_params)$)/' (the
rest_ filters except the per-route schema ones), an anonymous GET /wp-json/wp/v2/lp_book/4 logs from wp_loaded on:
registered_taxonomy_lp_series wp_connectors_init wp_loaded parse_request [rest_get_max_batch_size] rest_api_init
register_setting [rest_url] [rest_avatar_sizes] [rest_jsonp_enabled] [rest_exposed_cors_headers]
[rest_allowed_cors_headers] [rest_authentication_errors] [rest_pre_dispatch] [rest_endpoints]
[rest_request_parameter_order] [rest_request_before_callbacks] wp_error_added [rest_dispatch_request] the_post
wp_default_scripts parse_term_query pre_get_terms wp_cache_set_last_changed metadata_lazyloader_queued_objects
[rest_route_for_post_type_items] [rest_route_for_post] parse_tax_query parse_query pre_get_posts posts_selection
[rest_route_for_taxonomy_items] [rest_block_hooks_post_types] [rest_prepare_lp_book] [rest_request_after_callbacks]
[rest_post_dispatch] [rest_send_nocache_headers] [rest_pre_serve_request] [rest_request_from_url]
[rest_response_link_curies] [rest_pre_echo_response] [rest_json_encode_options] shutdown
-
rest_api_initfires only on REST requests, insideparse_request, when the REST server is created; routes and REST fields are registered there. The functionrest_api_init()that core attaches toinitonly adds the/wp-json/rewrite rules and therest_routequery variable. -
wp,template_redirect,wp_headand the theme templates never run, so code on those hooks does not affect REST responses; data for the block editor or a headless front end belongs in REST fields orrest_prepare_lp_book. -
The filters around dispatch are the extension points of the whole API:
rest_authentication_errors(deny before routing),rest_pre_dispatch(short-circuit),rest_request_before_callbacksandrest_request_after_callbacks(wrap the endpoint),rest_post_dispatch(change any response) andrest_pre_serve_request(serve another format).
A WP-CLI command fires cli_init during init and never runs wp() or the template loader. A wp-cron.php request
stops after wp_loaded and runs the due events' hooks, such as wp_scheduled_delete. An admin-ajax.php request
fires admin_init and then wp_ajax_{$action} or wp_ajax_nopriv_{$action}.
Pluggable functions
Before hooks covered everything, WordPress offered another way to change behaviour. wp-includes/pluggable.php, which
wp-settings.php loads after the plugins, wraps each of its functions in if ( ! function_exists( 'wp_mail' ) ),
so a plugin that defines a function of the same name first replaces core’s. In 7.1.3 there are 38, among them
wp_mail(), wp_get_current_user(), get_userdata(), get_user_by(), wp_authenticate(), wp_set_auth_cookie(),
wp_validate_auth_cookie(), auth_redirect(), wp_redirect(), wp_safe_redirect(), wp_verify_nonce(),
wp_create_nonce(), wp_salt(), wp_hash_password(), wp_check_password(), wp_password_needs_rehash() (6.8),
wp_generate_password(), wp_rand(), wp_new_user_notification() and get_avatar().
Replacing one is a last resort. Only one replacement can exist (the first plugin loaded wins, and a second unguarded
definition is a fatal "Cannot redeclare" error), the replacement must honour the whole contract of the original,
including parameters and hooks added later, and it must be defined when the plugin file loads, not in a hook. Filters
now cover most needs: pre_wp_mail (5.7) to short-circuit mail, wp_mail and phpmailer_init to change it,
wp_hash_password_algorithm and wp_hash_password_options (6.8, when hashing moved to bcrypt), wp_redirect and
allowed_redirect_hosts, nonce_life, and pre_get_avatar_data. Lumen Press logs mail on local sites with a filter:
<?php
/**
* Plugin Name: Lumen Mail Log
* Description: Writes outgoing mail to the debug log instead of sending it (local environments only).
*/
defined( 'ABSPATH' ) || exit;
if ( 'local' !== wp_get_environment_type() ) {
return;
}
// A filter, not a replacement of the pluggable wp_mail(): any other plugin can still hook mail.
add_filter(
'pre_wp_mail',
static function ( $short_circuit, array $atts ) {
$to = implode( ', ', (array) $atts['to'] );
error_log( sprintf( 'Mail not sent (local): to=%s subject="%s"', $to, $atts['subject'] ) );
return true; // Any non-null value stops wp_mail() and becomes its return value.
},
10,
2
);
Source: Code Reference: pre_wp_mail
Called from wp eval, wp_mail() returns true and the debug log shows
Mail not sent (local): to=maria.editor@lumenpress.test subject="New submission". When a pluggable function misbehaves,
( new ReflectionFunction( 'wp_mail' ) )->getFileName() shows who defined it; on this site it is
wp-includes/pluggable.php, line 189. Delivery itself is covered in
Email Delivery. Parent themes use the same function_exists() pattern so that a
child theme, whose functions.php loads first, can replace their functions (see
Child Themes). In plugins, use it only for code meant to be pluggable; prefixes or
namespaces avoid collisions everywhere else.
Security and performance
-
Hooks do not check permissions. Callbacks on
init,admin_initorwp_ajax_*run for whoever sends the request; code that changes data checkscurrent_user_can()and a nonce (Security for Developers).admin_initdoes not mean an administrator. -
Escape what filters return: any plugin can add rows to the details box, so every row is escaped. Callbacks run on every request where their hook fires: heavy work on
plugins_loadedorinitslows pages, REST, cron and AJAX. One-off setup (roles, tables, rewrite flushes) belongs in the activation hook (Plugin Fundamentals). Since 6.7,__()beforeinittriggers a "translation loading triggered too early" notice.
Where older books and tutorials are outdated
The All-in-One For Dummies book does not cover plugin development at all, and the other older books and many tutorials carry habits that no longer apply:
| Older advice or code | Current behaviour |
|---|---|
|
Roles are stored in the database: add them once on activation, remove them on uninstall |
Callbacks built with |
Removed in PHP 8.0; use a named function, a static method or a stored closure |
|
A PHP 4 idiom; |
Reading or editing |
Entries are |
Scripts enqueued on |
Enqueue on |
Markup injected with |
|
Redefining |
Prefer |
"Plugins cannot depend on each other", so probe with |
6.5 added the |
Common pitfalls
| Symptom | Cause and fix |
|---|---|
A callback never runs |
Added after its hook fired ( |
|
Different priority, instance or closure, or the removal runs before the callback is added |
"Too few arguments to function" |
|
A change also hits menus, widgets or REST |
|
A save hangs until a time or memory limit |
A |