WordPress Hooks: Actions, Filters, and How They Actually Work

Hooks are how WordPress lets plugins, themes, and your custom code change what the platform does without modifying core. They are the reason WooCommerce can hook into the order flow, why Yoast can rewrite the <title> tag, and why your child theme’s functions.php can disable file editing on every site you build. Once you understand hooks, every other piece of WordPress development clicks into place.

This guide covers the two types of hooks (actions and filters), how they actually differ, the parameters that trip people up ($priority and $accepted_args), how to remove hooks other code added, how to create your own, and the modern patterns (class methods, anonymous functions, Block Hooks in WordPress 6.4+) that most older tutorials miss.

What hooks are

WordPress runs through a sequence of events on every request: it loads plugins, sets up the database connection, parses the request, runs the main query, generates HTML, sends headers, fires shutdown handlers, and so on. At dozens of points along the way, it checks: “Is anyone listening for this event? If so, run their code.” Those checkpoints are hooks.

Code attaches to hooks. When the hook fires, every attached function runs. WordPress core fires hooks; your code (and every plugin and theme) listens for them. That listen-and-react pattern is what makes WordPress extensible without anyone needing to fork core.

Actions vs. filters: the core difference

WordPress has two kinds of hooks. The distinction is simple once you internalize it:

  • An action is an event. It says “this thing happened, do whatever you want.” Your callback runs and returns nothing. Examples: a post was published, a user logged in, the <head> section is being printed.
  • A filter is middleware. It says “I have this value, do you want to change it before I use it?” Your callback receives a value, transforms it, and returns the modified version. Examples: the post title before display, the excerpt length, the meta description.

The mental model: actions do things; filters transform things. If you find yourself attaching to an action and trying to return a value, you’re using the wrong hook type. If you find yourself attaching to a filter and forgetting to return the value, you’ve just broken whatever was using it.

Action hooks

The function for adding an action is add_action():

add_action( $hook_name, $callback, $priority = 10, $accepted_args = 1 );

A simple, common example: register a stylesheet on the front-end:

function smartwp_enqueue_styles() {
    wp_enqueue_style(
        'smartwp-main',
        get_stylesheet_directory_uri() . '/style.css',
        [],
        '1.0.0'
    );
}
add_action( 'wp_enqueue_scripts', 'smartwp_enqueue_styles' );

WordPress fires wp_enqueue_scripts when it’s ready to enqueue front-end assets. Our function listens for that, calls wp_enqueue_style(), and returns nothing. That’s the action pattern.

Another example: log every published post to a custom file:

function smartwp_log_published( $post_id, $post ) {
    if ( $post->post_status !== 'publish' ) {
        return;
    }
    error_log( "Post published: {$post->post_title} (ID {$post_id})" );
}
add_action( 'save_post', 'smartwp_log_published', 10, 2 );

Notice the 10, 2 at the end. That’s the priority (default 10) and the number of arguments to pass through (default 1). save_post passes both $post_id and the $post object, so we declare 2 to receive both. Skip $accepted_args when you only need the first argument; declare it when you need more.

Filter hooks

The function for adding a filter is add_filter(), with the same signature as add_action():

add_filter( $hook_name, $callback, $priority = 10, $accepted_args = 1 );

A common example: append a sentence to every post’s content:

function smartwp_append_signature( $content ) {
    if ( is_singular( 'post' ) ) {
        $content .= '<p class="signature">Thanks for reading!</p>';
    }
    return $content;
}
add_filter( 'the_content', 'smartwp_append_signature' );

The function receives $content (the post body), modifies it, and returns the modified version. WordPress uses the returned value instead of the original. The return statement is the entire point of a filter. If you forget it, the value becomes null and your post body disappears.

Another example: change the excerpt length from the default 55 words to 30:

function smartwp_short_excerpt( $length ) {
    return 30;
}
add_filter( 'excerpt_length', 'smartwp_short_excerpt' );

WordPress is asking “how long should excerpts be?” and we’re answering “30.” The default of 55 gets replaced with our value.

The priority parameter

Multiple callbacks can attach to the same hook. Priority controls the order they run in:

  • Lower priority number = runs earlier
  • Default priority is 10
  • Same priority = runs in registration order
add_action( 'init', 'first_callback',  5  );  // runs 1st
add_action( 'init', 'second_callback', 10 );  // runs 2nd
add_action( 'init', 'third_callback',  20 );  // runs 3rd

Two common priority tricks:

  • Priority 1 or even 0 when you want to run before anything else (rare, but useful for early bailouts).
  • Priority 999 when you want to run after everything else (common when modifying a value that other plugins also touch and you want the final say).

The $accepted_args parameter

Some hooks pass multiple arguments to your callback. By default, only the first one is passed. To get the rest, declare how many arguments you need:

// comment_post passes (comment_id, comment_approved, commentdata) - 3 args
function smartwp_log_new_comment( $comment_id, $approved, $commentdata ) {
    error_log( "New comment: {$comment_id}, approved: {$approved}" );
}
add_action( 'comment_post', 'smartwp_log_new_comment', 10, 3 );

If you write the callback to accept three parameters but forget the 3 at the end, PHP will throw “missing argument” warnings every time the comment is posted. The reverse (declaring 3 but accepting only 1 in the callback) is harmless, just wasteful.

Where to put hook code

Three good locations for hook code, from most-recommended to least:

A site-specific plugin or mu-plugin is the most flexible option. Create a single PHP file in wp-content/mu-plugins/ (must-use plugins load automatically and can’t be deactivated by accident), or wrap your customizations in a tiny custom plugin. Either approach keeps the code intact when you switch themes.

The Code Snippets plugin is the right call when you want a UI for managing snippets, especially on sites where multiple people will edit them. Each snippet is its own toggle, with priority controls and front-end/back-end scope settings exposed in the admin.

Your child theme’s functions.php works for theme-specific hooks (typography, layout, theme support declarations) but not for anything else. The code disappears the moment you switch themes. Avoid the parent theme’s functions.php entirely; updates will overwrite your changes.

Anonymous functions and class methods

The callback doesn’t have to be a named function. It can be:

An anonymous function (closure), useful for short one-offs:

add_filter( 'excerpt_length', function () {
    return 30;
} );

Anonymous functions can’t be removed later (no name to reference), so don’t use them when you might need to remove_filter the callback.

A class method, using array syntax:

class SmartWP_SEO {
    public function __construct() {
        add_filter( 'document_title_parts', [ $this, 'modify_title' ] );
    }

    public function modify_title( $parts ) {
        $parts['site'] = 'SmartWP';
        return $parts;
    }
}
new SmartWP_SEO();

Class methods are the standard pattern for plugins of any size. Use static methods ([ self::class, 'method' ]) or instance methods ([ $this, 'method' ]) depending on whether you need state.

Removing hooks

To unhook a callback added by core, a plugin, or a theme:

remove_action( $hook_name, $callback, $priority = 10 );
remove_filter( $hook_name, $callback, $priority = 10 );

Important gotcha: you must pass the same priority that was used to add the hook. If a plugin added a callback at priority 99 and you call remove_action with priority 10 (the default), nothing happens. Read the source to find the original priority.

Example: disable the WordPress emoji script in wp_head:

remove_action( 'wp_head',            'print_emoji_detection_script', 7 );
remove_action( 'wp_print_styles',    'print_emoji_styles' );
remove_filter( 'the_content_feed',   'wp_staticize_emoji' );
remove_filter( 'comment_text_rss',   'wp_staticize_emoji' );
remove_filter( 'wp_mail',            'wp_staticize_emoji_for_email' );

Note the priority 7 on the first line. The original add_action call in core uses 7, so the remove_action needs to match.

To remove a hook that was added with an anonymous function, you’re stuck. Anonymous closures have no name to reference, which is one reason most plugins use named functions or class methods.

Creating your own hooks

You can fire your own hooks so other code can listen in. Two functions:

  • do_action( 'hook_name', $arg1, $arg2, ... ) — fires an action. Anyone listening with add_action runs at that point.
  • $value = apply_filters( 'hook_name', $value, $arg1, ... ) — fires a filter. The first argument is the value being passed through; subsequent arguments are context. The return value is whatever the filter chain produced.

Example: a plugin that fires a custom action after sending a notification email:

function smartwp_send_notification( $user_id, $message ) {
    $user = get_userdata( $user_id );
    wp_mail( $user->user_email, 'Notification', $message );

    // Let other plugins react to the send
    do_action( 'smartwp_notification_sent', $user_id, $message );
}

Example: a custom filter that lets other code modify the email subject before sending:

function smartwp_send_notification( $user_id, $message ) {
    $user    = get_userdata( $user_id );
    $subject = apply_filters( 'smartwp_notification_subject', 'Notification', $user_id );
    wp_mail( $user->user_email, $subject, $message );
}

Naming convention: prefix every custom hook with your plugin or theme slug (smartwp_, woocommerce_, elementor/) to avoid colliding with core or other plugins. WooCommerce alone fires more than 1,000 custom hooks; that ecosystem only works because everything namespaces.

How to find hooks

The official reference at developer.wordpress.org/reference/hooks/ is a searchable list of every hook in core, with the file location, when it fires, and what arguments it passes. Bookmark it.

The Query Monitor plugin adds a debug bar to wp-admin and the front-end that shows every hook fired on the current request along with which functions are attached. It is the fastest way to find hooks for things you can already see on the page; install it, load the page, expand the Hooks panel, and search for the visible string you want to modify.

Reading source is the third option. Grep for do_action( and apply_filters( to find every hook a piece of code fires. Running grep -r "do_action\|apply_filters" wp-content/plugins/woocommerce on your server (or in your IDE) lists every WooCommerce hook in seconds; the same trick works for any plugin or theme you want to extend.

Block Hooks (WordPress 6.4+)

WordPress 6.4 introduced a feature called Block Hooks. Despite the name, these are not the same as the action and filter hooks above. Block Hooks let you tell a block-theme template “automatically insert this block after every Navigation block” or “before every Comments block” without modifying the theme files.

If you’re following a tutorial that mentions Block Hooks, that’s the feature; if you’re trying to learn the underlying extension system that powers most of WordPress, that’s actions and filters (this guide). The naming overlap is unfortunate but the concepts are distinct. Block Hooks landed in WordPress 6.4; the action/filter system you’re reading about now has been in WordPress since version 1.2 (2004).

Common mistakes

Forgetting to return a value from a filter callback is the most common bug. The post body, title, or whatever the filter was carrying becomes null and disappears. Every filter callback ends in return; double-check before pushing.

Mixing up actions and filters is the second most common. If you find yourself wanting to “change” something, that’s a filter. If you find yourself wanting to “do” something in response to an event, that’s an action. Trying to return a value from an action is harmless but pointless; trying to do work without returning from a filter is destructive.

Calling remove_action at the wrong priority is the third trap. The priority must match what was used to add the hook, not the default 10. Look at the source.

Using anonymous functions for hooks you might want to remove later is the fourth. Closures have no callable reference, so you cannot unhook them. For anything beyond a one-line throwaway, use a named function or class method.

Hooking into init for everything is a beginner habit worth breaking. init fires very early; many WordPress functions (like checking the current user or template) aren’t available yet. Use the most specific hook for the job: wp_enqueue_scripts for assets, save_post for post saves, template_redirect for routing logic, etc.

Frequently asked questions

What is the difference between an action and a filter?

An action is an event you respond to (a post is saved, a user logs in, the page header is being printed). Your callback runs and does work but returns nothing. A filter is a value-transformer (the post title before display, the meta description, the comment text). Your callback receives a value, modifies it, and returns the modified version. The mental model: actions do things; filters change things.

Where do I add hooks in WordPress?

Three good locations: a site-specific plugin or mu-plugin (most flexible), the Code Snippets plugin (UI-managed), or your child theme’s functions.php (fine for theme-related code). Avoid the parent theme’s functions.php because updates overwrite your changes.

How do I find what hooks are available on a WordPress page?

Install the Query Monitor plugin. It adds a debug bar that shows every hook fired on the current request, along with the functions attached to each one. For a complete reference, the official hook list lives at developer.wordpress.org/reference/hooks/. Or grep for do_action( and apply_filters( in the source you care about.

Why is my filter callback making the post body blank?

You forgot to return the value. Every filter callback must return something, even if it didn’t change the input. The pattern is: receive the value, modify it (or not), then return $value;. If you skip the return, the value becomes null and whatever the filter was carrying disappears.

How do I remove a hook added by a plugin?

Use remove_action() or remove_filter(), but you must pass the same priority the original hook was added with. If a plugin used priority 99 and you call remove_action at priority 10 (the default), nothing happens. Look at the plugin source to find the priority. Note: anonymous functions can’t be removed at all because they have no name to reference.

What is the priority parameter for?

Multiple callbacks can attach to the same hook. Priority controls the order they run in: lower numbers run earlier, higher numbers run later, and the default is 10. Use priority 1 to run before nearly everything, or priority 999 to run after everything (common when you want the final word on a value other code is also modifying).

Wrap-up

Hooks are the API surface for nearly every customization you’ll ever make in WordPress. Internalize the actions-vs-filters distinction, get comfortable with priority and $accepted_args, prefer named functions or class methods over anonymous closures, and use Query Monitor or the developer reference to discover what’s available. From there, every plugin tutorial, every functions.php snippet, and every WooCommerce extension you’ll ever read makes immediate sense.

Picture of Andy Feliciotti

Andy Feliciotti

Andy has been a full time WordPress developer for over 15 years. Through his years of experience has built 100s of sites and learned plenty of tricks along the way. Found this article helpful? Buy Me A Coffee

Leave a Reply

Your email address will not be published. Required fields are marked *

WordPress Tips Monthly
Get the latest from SmartWP to your inbox.