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

do_action( $hook_name, ...$args ), do_action_ref_array()

apply_filters( $hook_name, $value, ...$args ), apply_filters_ref_array()

Attached with

add_action()

add_filter()

Callback’s return value

Ignored

Input of the next callback, then the result of apply_filters()

Side effects

Expected: register, save, send, print

To be avoided: no output, no globals, no database writes

Examples

init, save_post, wp_enqueue_scripts, rest_api_init

the_content, the_title, template_include, rest_prepare_lp_book

Counted by

did_action()

did_filter() (6.1+)

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

Any PHP callable works, and its form decides how it can be removed later:

Form Example Removable with

Function name

'lumen_catalog_append_book_details'

The same string

Static method

array( 'Lumen_Catalog_Stock', 'instance' ) or 'Lumen_Catalog_Stock::instance'

The same class and method

Object method

array( $this, 'mark_out_of_stock' )

The same instance and method

Closure or arrow function

static fn( $title ) => ...

Only the same closure object, kept in a variable

Invokable object

an instance of a class with __invoke()

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

apply_block_hooks_to_content_from_post_object, WP_Embed->run_shortcode, WP_Embed->autoembed

9

do_blocks: block markup becomes HTML here

10

wptexturize, wpautop, shortcode_unautop, prepend_attachment, wp_replace_insecure_home_url

11

capital_P_dangit, do_shortcode

12

wp_filter_content_tags: loading, srcset, sizes and decoding on images and iframes

20

convert_smilies

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:

wp-content/plugins/lumen-catalog/includes/book-details.php
<?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>';
}

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::method for a static method, an instance ID for objects and closures (on the test site 1961mark_out_of_stock for the stock badge below). Since 7.1 that ID comes from spl_object_id() instead of spl_object_hash(), and since 7.1.1 a closure’s ID has an spl_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:

wp-content/plugins/lumen-catalog/includes/class-lumen-catalog-stock.php
<?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' ) );

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:

hook-removal-demo.php
<?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 unprefixed book_imported run 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 @param per 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:

wp-content/plugins/lumen-catalog/includes/import.php
<?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:

wp-content/mu-plugins/lumen-site-tweaks.php (after the plugin header and the ABSPATH check)
// 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;
'
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

save_post_{$post->post_type}

save_post_lp_book

After a book is saved, before the generic save_post

{$new_status}_{$post->post_type}

publish_lp_book

When a book enters the publish status

rest_prepare_{$this->post_type} (filter)

rest_prepare_lp_book

Before the REST API returns a book

manage_{$post_type}_posts_columns (filter)

manage_lp_book_posts_columns

Building the columns of the book list

option_{$option}, update_option_{$option}

option_lp_catalog_settings

Reading or updating one option

load-{$pagenow}, load-{$page_hook}

load-edit.php

Before an admin screen is rendered

lumen_catalog_import_row_{$source} (filter)

lumen_catalog_import_row_distributor

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:

wp-content/plugins/lumen-catalog/includes/default-excerpt.php
<?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

The second command prints the first 20 words followed by &hellip;. 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

did_action( $hook ), did_filter( $hook ) (6.1+)

How many times the hook fired in this request: the usual "too early?" and "only once" guard

doing_action( $hook = null ), doing_filter( $hook = null )

Whether that hook is running now, anywhere in the stack; without an argument, whether any hook is. Both read the same stack: doing_action() calls doing_filter()

current_filter(), current_action()

The innermost hook running now; one callback attached to several hooks can branch on it

has_action(), has_filter()

Whether a hook has callbacks, or the priority of one callback (which can be 0, so compare with false using ===); since 6.9 an optional priority argument makes it return a boolean

The all hook

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:

wp-content/mu-plugins/lumen-hook-log.php
<?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

sequenceDiagram autonumber participant W as wp-settings.php participant P as Plugins (MU, then regular) participant T as Theme functions.php participant M as wp() and WP_Query participant C as template-canvas.php W->>P: include each must-use plugin W-->>P: mu_plugin_loaded, muplugins_loaded, registered_* for core types W->>P: include each active plugin W-->>P: plugin_loaded, plugins_loaded, sanitize_comment_cookies, wp_roles_init, setup_theme W->>T: include child, then parent functions.php W-->>T: load_textdomain, after_setup_theme W-->>P: set_current_user, init (core types at 0, widgets_init at 1, plugin types at 10) W-->>P: wp_loaded M-->>P: parse_request, parse_query, pre_get_posts, posts_selection, send_headers, wp C-->>P: template_redirect, wp_before_include_template C->>C: render the whole block template C-->>P: loop_start, the_post, loop_end (Query Loop and Post Content blocks) C-->>P: wp_head (wp_enqueue_scripts at 1, enqueue_block_assets, wp_print_styles, wp_print_scripts) C-->>P: wp_body_open, then echo the rendered template C-->>P: wp_footer (wp_print_footer_scripts at 20) W-->>P: shutdown

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_init from init at priority 1, after its own post types and taxonomies (0), and wp_connectors_init (the AI Connectors registry, 7.0) at 15; the plugin’s lp_book and lp_series, registered on init at 10, fall between them.

  • The current user is resolved on first use: here just before init, on the admin request below before after_setup_theme. Capability checks belong on init or later.

  • template-canvas.php renders the whole block template before calling wp_head(), so in a block theme the loop hooks fire before wp_head and get_header, get_footer and get_sidebar never fire. Blocks can thus enqueue assets while rendering and still have them printed in the head. A classic theme calls get_header() first.

  • wp_enqueue_scripts is fired by a callback on wp_head at priority 1; enqueueing on wp_head is too late.

  • The first request after installation also fired save_post and publish_wp_navigation while rendering, because the Navigation block created its fallback wp_navigation menu. 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_menu fires before admin_init, because admin.php builds the menu first: menu pages are added on admin_menu, settings on admin_init (see Administration Menus and Settings API). admin_init also fires in admin-ajax.php and admin-post.php.

  • current_screen and load-{$pagenow} are the first points where the screen is known (help tabs, handling a form posted to the same screen). admin_enqueue_scripts receives the hook suffix (edit.php), so assets can be limited to the plugin’s own screens.

  • List screens call wp(), so parse_request, pre_get_posts and wp fire in the admin too; a front-end pre_get_posts callback 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_init fires only on REST requests, inside parse_request, when the REST server is created; routes and REST fields are registered there. The function rest_api_init() that core attaches to init only adds the /wp-json/ rewrite rules and the rest_route query variable.

  • wp, template_redirect, wp_head and 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 or rest_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_callbacks and rest_request_after_callbacks (wrap the endpoint), rest_post_dispatch (change any response) and rest_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:

wp-content/mu-plugins/lumen-mail-log.php
<?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
);

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_init or wp_ajax_* run for whoever sends the request; code that changes data checks current_user_can() and a nonce (Security for Developers). admin_init does 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_loaded or init slows pages, REST, cron and AJAX. One-off setup (roles, tables, rewrite flushes) belongs in the activation hook (Plugin Fundamentals). Since 6.7, __() before init triggers 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

add_role() called on every init (one book’s example)

Roles are stored in the database: add them once on activation, remove them on uninstall

Callbacks built with create_function()

Removed in PHP 8.0; use a named function, a static method or a stored closure

array( &$this, 'method' )

A PHP 4 idiom; array( $this, 'method' ) is enough

Reading or editing $wp_filter as nested arrays

Entries are WP_Hook objects since 4.7 and object IDs changed in 7.1; use the hook functions

Scripts enqueued on init or printed in wp_head

Enqueue on wp_enqueue_scripts or admin_enqueue_scripts; block assets come from block.json

Markup injected with get_header or loop_start

get_header never fires in block themes and loop hooks run before wp_head; use blocks, Block Hooks or block filters

Redefining wp_mail() or wp_hash_password()

Prefer pre_wp_mail, phpmailer_init and the 6.8 hashing filters

"Plugins cannot depend on each other", so probe with function_exists()

6.5 added the Requires Plugins header; hook into the dependency’s own actions

Common pitfalls

Symptom Cause and fix

A callback never runs

Added after its hook fired (add_action( 'plugins_loaded', ... ) in a theme), a misspelled name, or a front-end hook on a REST request. Check did_action() where the callback is added

remove_action() returns false

Different priority, instance or closure, or the removal runs before the callback is added

"Too few arguments to function"

$accepted_args is lower than the callback’s required parameters

A change also hits menus, widgets or REST

the_title and the_content fire everywhere; check in_the_loop(), is_main_query() or the post type

A save hangs until a time or memory limit

A save_post callback updates the same post without detaching itself (see above)