Type something to search...
How to Use WordPress Hooks: Actions and Filters Explained?

How to Use WordPress Hooks: Actions and Filters Explained?

Every WordPress site runs on the same core files, yet no two sites behave the same. One adds a tracking script to the footer, another changes the excerpt length, a third sends a Slack message whenever a post is published. None of them edits WordPress core to do it. They all use hooks, the extension system that every theme and plugin in the ecosystem is built on.

Once you understand hooks, most WordPress customization stops looking like magic. Nearly every "add this snippet to your functions.php" tutorial you've followed was really one of two things: hooking a function into an action, or hooking one into a filter. This guide explains the difference between the two, how WordPress runs them, and how to use them in real code, including creating your own hooks, removing other people's, and debugging when a hook doesn't seem to fire.

What Are WordPress Hooks?

A hook is a named point in WordPress's execution where it pauses and says, in effect, "does anyone want to run code here?" WordPress core fires thousands of these named points while it loads a page: when plugins finish loading, when the theme is set up, when the <head> is printed, when a post is saved, when the content of a post is about to be displayed, and many more.

Your code attaches a function (a callback) to one of those names. When WordPress reaches that point, it runs every callback attached to it, in order.

There are two kinds of hooks, and the difference is what they expect back from you:

  • Actions let you do something at a specific moment: output HTML, send an email, enqueue a script, write to the database. WordPress ignores any return value.
  • Filters let you change something before WordPress uses it: a post's content, an excerpt's length, a page title, a list of CSS classes. WordPress passes your callback a value, and your callback must return a value (the original or a modified version).

A simple way to remember it: actions are about events, filters are about data.

Where to Put Your Hook Code

Hook callbacks have to live somewhere WordPress loads on every request. You have two good options:

  1. A child theme's functions.php: fine for code that's tied to how your theme looks. Don't edit a parent theme directly, because updates overwrite your changes. See what a WordPress child theme is if you haven't set one up.
  2. A small custom plugin: the better choice for functionality you'd want to keep if you switched themes, like custom post types, integrations, or admin tweaks.

A minimal plugin is just a single PHP file in wp-content/plugins/tidewave-tweaks/tidewave-tweaks.php:

<?php
/**
 * Plugin Name: TideWave Tweaks
 * Description: Site-specific hooks and customizations.
 * Version:     1.0.0
 */

defined( 'ABSPATH' ) || exit;

// Your add_action() and add_filter() calls go here.

Activate it under Plugins, and every hook you add to that file runs on your site. If you want to understand more about how plugins load, see what WordPress plugins are and how they work.

How Actions Work

You attach a callback to an action with add_action():

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

WordPress core (or a theme or plugin) fires that action with do_action():

do_action( 'wp_footer' );

When do_action( 'wp_footer' ) runs, every callback attached to wp_footer executes.

Example: Add Code to the Site Footer

The wp_footer action fires just before the closing </body> tag in any properly built theme. It's a common place to output small scripts or markup:

add_action( 'wp_footer', 'tw_footer_notice' );

function tw_footer_notice() {
    if ( ! is_singular( 'post' ) ) {
        return;
    }

    echo '<div class="tw-footer-notice">Thanks for reading! New posts go out every week.</div>';
}

The callback echoes its output directly, because actions don't use return values. Anything you return from an action callback is simply thrown away.

Example: Enqueue a Stylesheet and Script

The correct way to load CSS and JavaScript in WordPress is through the wp_enqueue_scripts action, not by hard-coding <link> or <script> tags into a template:

add_action( 'wp_enqueue_scripts', 'tw_enqueue_assets' );

function tw_enqueue_assets() {
    wp_enqueue_style(
        'tw-custom',
        get_stylesheet_directory_uri() . '/assets/css/custom.css',
        [],
        '1.0.0'
    );

    wp_enqueue_script(
        'tw-custom',
        get_stylesheet_directory_uri() . '/assets/js/custom.js',
        [],
        '1.0.0',
        [ 'strategy' => 'defer', 'in_footer' => true ]
    );
}

Enqueuing through the hook lets WordPress handle dependencies, avoid loading the same file twice, and let caching or optimization plugins work with your assets.

Example: Run Code When a Post Is Published

Some actions pass data to your callback. save_post fires whenever a post is created or updated and passes three arguments: the post ID, the post object, and whether this is an update. To receive more than one argument, set $accepted_args:

add_action( 'save_post_post', 'tw_log_published_post', 10, 3 );

function tw_log_published_post( $post_id, $post, $update ) {
    // Ignore autosaves and revisions.
    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
        return;
    }

    if ( 'publish' !== $post->post_status ) {
        return;
    }

    error_log( sprintf( 'Post #%d "%s" was saved.', $post_id, $post->post_title ) );
}

Notice the hook name: save_post_post. WordPress fires a dynamic version of save_post for each post type, save_post_{$post_type}, so save_post_product only fires for WooCommerce products and save_post_page only for pages. Using the specific version saves you a type check inside the callback.

If you leave $accepted_args at its default of 1, your callback only receives $post_id. Asking for three parameters in the function signature without raising $accepted_args causes an ArgumentCountError in PHP 8.

Commonly Used Actions

ActionWhen it firesTypical use
plugins_loadedAfter all active plugins are loadedBootstrapping plugin code, checking for other plugins
after_setup_themeAfter the theme's functions.php loadsadd_theme_support(), registering menus and image sizes
initAfter WordPress is loaded, before headers are sentRegistering post types, taxonomies, shortcodes
wp_enqueue_scriptsWhen front-end assets are being queuedLoading CSS and JS
admin_enqueue_scriptsWhen admin assets are being queuedLoading CSS and JS in the dashboard
wp_head / wp_footerInside <head> / before </body>Meta tags, analytics, small inline scripts
save_postWhen a post is created or updatedSaving meta fields, clearing caches
template_redirectBefore WordPress picks a templateRedirects, access control

You'll see init used constantly. For example, creating custom post types in WordPress and creating a WordPress shortcode both register their code on it.

How Filters Work

Filters use a matching pair of functions. You attach a callback with add_filter():

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

And the code that owns the data runs it through the filter with apply_filters():

$content = apply_filters( 'the_content', $content );

apply_filters() passes the value to the first callback, takes what it returns, passes that to the next callback, and so on. The final result is what WordPress uses. That chain is why every filter callback must return a value. If you forget, your callback returns null, and whatever you were filtering disappears from the page.

Example: Change the Excerpt Length

WordPress trims automatic excerpts to 55 words. The excerpt_length filter lets you change that number:

add_filter( 'excerpt_length', 'tw_excerpt_length' );

function tw_excerpt_length( $length ) {
    return 30;
}

And excerpt_more controls the […] string added to the end:

add_filter( 'excerpt_more', 'tw_excerpt_more' );

function tw_excerpt_more( $more ) {
    return '&hellip; <a class="read-more" href="' . esc_url( get_permalink() ) . '">Continue reading</a>';
}

Example: Modify Post Content

the_content is probably the most widely used filter in WordPress. It runs on a post's body before it's displayed, which makes it the right place to append or prepend content:

add_filter( 'the_content', 'tw_append_author_note' );

function tw_append_author_note( $content ) {
    // Only on single blog posts, only in the main query loop.
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content; // Always return the original value when you're not changing it.
    }

    $note = '<p class="author-note">Found this helpful? Share it with a teammate.</p>';

    return $content . $note;
}

The early return $content; matters. the_content also runs in widgets, feeds, and other plugins' output, so your callback needs to pass the value through unchanged whenever the conditions aren't right.

Example: Add a Class to the <body> Tag

Filters often deal with arrays rather than strings. body_class passes an array of class names:

add_filter( 'body_class', 'tw_body_class' );

function tw_body_class( $classes ) {
    if ( is_user_logged_in() ) {
        $classes[] = 'is-member';
    }

    return $classes;
}

Example: Filters With Multiple Arguments

Like actions, filters can pass extra context after the value being filtered. login_redirect passes the redirect URL, the originally requested URL, and the user object:

add_filter( 'login_redirect', 'tw_login_redirect', 10, 3 );

function tw_login_redirect( $redirect_to, $requested_redirect_to, $user ) {
    if ( $user instanceof WP_User && in_array( 'subscriber', (array) $user->roles, true ) ) {
        return home_url( '/members/' );
    }

    return $redirect_to;
}

Subscribers land on a members page after logging in, and everyone else goes wherever WordPress would normally send them. Only the first argument is the value being filtered. The rest are read-only context.

Understanding Priority

The third parameter of add_action() and add_filter() is the priority, an integer that controls execution order. Lower numbers run first, and the default is 10. Callbacks with the same priority run in the order they were added.

add_filter( 'the_title', 'tw_title_first', 5 );   // Runs first
add_filter( 'the_title', 'tw_title_default' );     // Runs second (priority 10)
add_filter( 'the_title', 'tw_title_last', 99 );    // Runs last

Priority is how you resolve conflicts. If a plugin modifies a value at priority 10 and you need to change the result afterward, hook in at 20 or higher. If you need to act before everyone else, use a low number like 1. Use PHP_INT_MAX only when you really mean "absolutely last," since it leaves no room for anything to run after you.

Using Anonymous Functions and Class Methods

The $callback parameter accepts any PHP callable, not just named functions.

Anonymous functions are handy for short, one-off hooks:

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

The trade-off is that anonymous functions can't be removed later with remove_filter(), because there's no name to refer to them by. Use named functions for anything another developer (or you, later) might need to unhook.

Class methods are the standard pattern in object-oriented plugins. Pass an array of the object and the method name:

class TW_Newsletter {

    public function __construct() {
        add_action( 'wp_footer', [ $this, 'render_signup_form' ] );
        add_filter( 'the_content', [ $this, 'add_inline_cta' ] );
    }

    public function render_signup_form() {
        echo '<form class="tw-newsletter">...</form>';
    }

    public function add_inline_cta( $content ) {
        if ( ! is_singular( 'post' ) ) {
            return $content;
        }

        return $content . '<p class="tw-cta">Subscribe for weekly tutorials.</p>';
    }
}

new TW_Newsletter();

For static methods, use the string form: [ 'TW_Newsletter', 'method_name' ] or 'TW_Newsletter::method_name'.

Removing Hooks Added by Themes and Plugins

Sometimes the hook you need to deal with isn't yours. A theme prints something you don't want, or a plugin filters content in a way that breaks your layout. remove_action() and remove_filter() undo an earlier add_action() or add_filter():

remove_action( 'wp_head', 'wp_generator' ); // Removes the WordPress version meta tag.

Two rules trip up almost everyone:

  1. The priority must match exactly. If the original code used add_action( 'wp_footer', 'theme_credits', 15 ), then remove_action( 'wp_footer', 'theme_credits' ) does nothing, because it looks at priority 10. You need remove_action( 'wp_footer', 'theme_credits', 15 ).
  2. You must remove the hook after it's been added. If your code runs before the theme or plugin registers its callback, there's nothing to remove yet. Wrap the removal in a later hook:
add_action( 'after_setup_theme', 'tw_remove_parent_theme_hooks', 20 );

function tw_remove_parent_theme_hooks() {
    remove_action( 'wp_footer', 'parent_theme_credits', 15 );
    remove_filter( 'excerpt_more', 'parent_theme_excerpt_more' );
}

Removing a class method requires the same object instance that added it. Well-built plugins make that instance accessible through a global, a singleton, or a function, for example remove_action( 'wp_footer', [ some_plugin(), 'render_badge' ] );. If a plugin never exposes its instance, you usually can't remove its hooks cleanly, and the better fix is overriding the output with a later-priority filter instead.

Creating Your Own Custom Hooks

Hooks aren't only for consuming. If you build a theme or plugin that other people will extend, or that you will extend from a child theme, adding your own hooks makes it customizable without forking the code.

Add an action where other code might want to output something:

function tw_render_newsletter_box() {
    echo '<div class="tw-newsletter-box">';

    do_action( 'tw_before_newsletter_form' );

    echo '<form class="tw-newsletter">...</form>';

    do_action( 'tw_after_newsletter_form' );

    echo '</div>';
}

Add a filter wherever a value might need to change:

function tw_get_newsletter_heading() {
    $heading = __( 'Get new tutorials in your inbox', 'tidewave' );

    /**
     * Filters the heading shown above the newsletter signup form.
     *
     * @param string $heading The default heading text.
     */
    return apply_filters( 'tw_newsletter_heading', $heading );
}

Now a child theme can change the heading without touching the plugin's code:

add_filter( 'tw_newsletter_heading', function () {
    return 'Join 5,000 developers learning WordPress';
} );

A few conventions keep custom hooks easy to work with:

  • Prefix every hook name (tw_ here) so it can't collide with core or another plugin.
  • Document the parameters with a docblock like the one above, since that's how other developers discover what a filter passes.
  • Pass useful context. If a filter's result depends on the current post, pass the post ID as a second argument: apply_filters( 'tw_newsletter_heading', $heading, $post_id ).

Checking and Debugging Hooks

When a hook doesn't seem to run, these functions narrow down why:

// Is anything attached? Returns the priority (int) or false.
has_action( 'wp_footer', 'tw_footer_notice' );
has_filter( 'the_content', 'tw_append_author_note' );

// How many times has an action fired so far on this request?
did_action( 'init' );

// Which hook is running right now? Useful inside a shared callback.
current_filter();

did_action() is especially useful for guarding code that has to run after a certain point:

if ( ! did_action( 'init' ) ) {
    _doing_it_wrong( __FUNCTION__, 'Call this after the init action.', '1.0.0' );
    return;
}

For a visual view, the free Query Monitor plugin has a "Hooks & Actions" panel that lists every hook fired on the current page, along with every callback attached to it and its priority. It's the fastest way to answer "is my callback even registered?" and "what else is hooked here that could be overriding me?"

Under the hood, all registered hooks live in the global $wp_filter array (actions and filters share the same storage, since an action is technically just a filter whose return value is ignored). You can inspect it for a single hook:

add_action( 'wp_footer', function () {
    global $wp_filter;

    if ( current_user_can( 'manage_options' ) && isset( $wp_filter['the_content'] ) ) {
        echo '<pre>' . esc_html( print_r( $wp_filter['the_content']->callbacks, true ) ) . '</pre>';
    }
}, 999 );

Only leave debugging code like this in place on a local or staging site, never on production.

Common Mistakes With Hooks

  • Forgetting to return a value from a filter. The single most common bug. The filtered content, title, or setting silently vanishes.
  • Echoing inside a filter. Filters should return strings, not print them. Output from echo inside the_content appears at the wrong place on the page, usually above everything else.
  • Hooking too early or too late. Calling register_post_type() outside init, or adding a wp_head callback after the head has already been printed, means your code runs at the wrong time or not at all.
  • Mismatched $accepted_args. Your function asks for three parameters, but add_action() was left at the default of one.
  • Mismatched priority in remove_action(). The removal silently does nothing.
  • Running expensive code on every request. A callback on init runs on every front-end page, admin page, AJAX call, and REST request. Check conditions early and return, or cache expensive results.

Frequently Asked Questions (FAQ) About WordPress Hooks

An action runs your code at a specific moment and ignores anything you return, which makes it right for outputting HTML, sending emails, or saving data. A filter passes your code a value and uses whatever you return, which makes it right for modifying content, settings, or other data before WordPress uses them.

Technically yes, since add_action() is a thin wrapper around add_filter() and both store callbacks in the same place. But you shouldn't mix them. Using the matching function for the hook type keeps your code readable and makes it obvious whether your callback needs to return a value.

The WordPress developer reference at developer.wordpress.org lists every core hook with its parameters. To see the hooks that actually fire on a specific page, including ones added by your theme and plugins, install the Query Monitor plugin and open its "Hooks & Actions" panel.

Almost always one of two reasons: the priority you passed doesn't match the priority the callback was added with, or your removal ran before the original add_action() call. Match the priority exactly and run the removal on a later hook such as after_setup_theme or init.

Priority controls execution order when multiple callbacks are attached to the same hook. Lower numbers run first, and the default is 10. Use a higher number to run after other plugins and override their changes, or a lower number to run before them.

Use a child theme's functions.php for code that's about your theme's appearance. Use a small custom plugin for functionality you'd want to keep if you changed themes, like integrations, custom post types, or admin changes. Never edit a parent theme or a third-party plugin directly, because updates overwrite your changes.

The hook system itself is extremely fast, since WordPress fires thousands of hooks on every request. What can slow a site down is the code inside your callbacks. Keep callbacks on frequently fired hooks like init or the_content lightweight, return early when they don't apply, and cache expensive results.

Conclusion

Hooks are the reason WordPress can be customized so heavily without anyone touching core files. Actions let you run code at a specific moment, and filters let you change data before WordPress uses it. Both follow the same pattern: find the right hook name, attach a callback with add_action() or add_filter(), and set a priority and argument count when the defaults aren't enough.

Start with the hooks you'll use most often (init, wp_enqueue_scripts, the_content, and save_post), keep your callbacks in a child theme or a small custom plugin, and always return a value from filters. Once those habits are in place, adding your own do_action() and apply_filters() calls is the natural next step toward writing themes and plugins that other developers can extend as easily as you extend WordPress.

Tags :
Share :

Related Posts

Effortlessly Crafting Compelling WordPress Pages

Effortlessly Crafting Compelling WordPress Pages

As a website owner or content creator, having the ability to seamlessly add new pages to your WordPress site is crucial. Whether you're introducing a

Continue Reading
High Traffic Tips for WordPress Mastery 🚥

High Traffic Tips for WordPress Mastery 🚥

In our digital age, where online visibility is paramount, ensuring your WordPress site can handle surging traffic is crucial. Just like a finely-tune

Continue Reading
How Do I Change the WordPress Login URL?

How Do I Change the WordPress Login URL?

By default, every WordPress site's login page lives at the same predictable address: yoursite.com/wp-login.php (which also happens to redirect from

Continue Reading