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.
add_action( $hook, $callback ) — do something; add_filter( $hook, $callback ) — change something and return itA 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
<?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.
// 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 pointInspecting 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.
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.
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