Hooks Explained

Hooks are WordPress's event system: actions run code at a moment in the request, filters change a value on its way through.

Syntaxadd_action( $hook, $callback ) — do something; add_filter( $hook, $callback ) — change something and return it

A hook is a named point in WordPress's execution where your code can join in. Core, and every well-written plugin and theme, announces these points as it runs, so you never have to edit a core file to change behaviour. You register a callback against a name, and WordPress calls it.

There are two kinds. An action means "this just happened": you do something (send mail, register a post type, print a tag) and return nothing. A filter means "here is a value before I use it": you receive it, change it or leave it alone, and must return it.

They are one system, not two

The split is a convention. Both kinds live in the same global $wp_filter array of WP_Hook objects, and in WordPress 6.8 add_action() is literally a wrapper that calls add_filter() with the same four arguments. do_action() is apply_filters() with the result discarded. Longhand and shorthand of the same machinery.

What trips people up

Registering is free and silent. add_action() and add_filter() both return true unconditionally, without checking that the hook name exists or that the callback is callable, so a typo produces no error at all and your code simply never runs. And a hook fires once and moves on: register after that moment in the request and you have missed it entirely, again with no warning.

Example

Example · php
<?php
/**
 * Plugin Name: Reading Time
 */

// ACTION: run at a point in the request. Returns nothing.
add_action( 'init', 'rt_register_meta' );
function rt_register_meta() {
	register_post_meta(
		'post',
		'rt_minutes',
		array(
			'type'         => 'integer',
			'single'       => true,
			'show_in_rest' => true,
		)
	);
}

// FILTER: receive a value, return a value. Never echo from here.
add_filter( 'the_content', 'rt_prepend_reading_time' );
function rt_prepend_reading_time( $content ) {
	// the_content runs in feeds, widgets and secondary loops too, so be specific.
	if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
		return $content; // Unchanged is still a valid return.
	}

	$words   = str_word_count( wp_strip_all_tags( $content ) );
	$minutes = max( 1, (int) ceil( $words / 200 ) );

	return '<p>' . esc_html( sprintf( '%d min read', $minutes ) ) . '</p>' . $content;
}

When to use it

  • Add a byline or banner to every single post without editing the theme's templates.
  • Register a custom post type from a plugin at the correct point in the load sequence.
  • Change a core default that has no settings screen, such as excerpt length or allowed upload types.
  • Run cleanup or logging after a post is saved, from a plugin that must survive theme changes.

More examples

add_action() is add_filter()

Shows that the action/filter distinction is naming discipline, not two code paths. Worth knowing when you read core: if you ever see add_filter() used on something that looks like an action, it works, and the reverse works too. Use the conventional name anyway so the next reader knows whether a return value is expected.

Example · php
// wp-includes/plugin.php, WordPress 6.8 — the whole of add_action():
function add_action( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
	return add_filter( $hook_name, $callback, $priority, $accepted_args );
}

// So these two register exactly the same thing. The first is the conventional form.
add_action( 'wp_footer', 'acme_footer_notice' );
add_filter( 'wp_footer', 'acme_footer_notice' ); // Identical effect. Do not write this.

// Firing is symmetrical too:
do_action( 'acme_order_placed', $order_id );                  // return values discarded
$config = apply_filters( 'acme_order_config', $config );      // return value is the point

Inspecting what is registered

The fastest way to answer "why is something else mangling my content?" or "did my callback even get registered?". Drop it in a mu-plugin while debugging. The $id keys are what WordPress uses internally to identify a callback, which is also what remove_filter() has to match.

Example · php
add_action( 'shutdown', function () {
	global $wp_filter; // WP_Hook[] keyed by hook name.

	if ( ! isset( $wp_filter['the_content'] ) ) {
		return;
	}

	// WP_Hook::$callbacks is keyed by priority, in ascending numeric order.
	foreach ( $wp_filter['the_content']->callbacks as $priority => $callbacks ) {
		foreach ( $callbacks as $id => $registered ) {
			error_log( sprintf( 'the_content [%d] %s', $priority, $id ) );
		}
	}

	// did_action() returns how many times an action has fired this request.
	error_log( 'init fired ' . did_action( 'init' ) . ' time(s)' );
} );

Checking before you assume

Use this when you need to unhook something another plugin registered but you do not know the priority it used. In WordPress 6.8 has_filter() and has_action() take only ($hook_name, $callback); the third $priority parameter arrived after 6.8, so do not rely on it in code that must run on 6.8.

Example · php
add_action( 'init', function () {
	// has_action( $hook, $callback ) returns the priority as an int, or false.
	// A priority of 0 is falsy, so compare with === when you only want yes/no.
	$priority = has_action( 'wp_head', 'wp_generator' );

	if ( false !== $priority ) {
		remove_action( 'wp_head', 'wp_generator', $priority );
	}

	// Without a callback, has_action() just answers "is anything listening?"
	if ( has_filter( 'the_content' ) ) {
		// ...
	}
} );

Discussion

  • Be the first to comment on this lesson.