Written by 7:21 am Uncategorized Views: 3

Understanding WordPress Hooks: Actions and Filters Explained Simply

WordPress hooks explained: actions do things, filters change things — add_action and add_filter code examples

If you have spent any time building WordPress themes or plugins, you have almost certainly encountered the term “hooks.” They are everywhere in WordPress core, in third-party plugins, and in well-structured themes. Yet despite their ubiquity, hooks remain one of the most misunderstood concepts for developers who are moving beyond basic template editing. This guide will give you a thorough, practical understanding of how WordPress hooks work, when to use actions versus filters, and how to leverage both to write cleaner, more extensible code.

By the end of this article, you will know exactly how add_action(), add_filter(), do_action(), and apply_filters() work under the hood, how priority and accepted arguments affect execution order, how to remove hooks, how to create your own, and how to avoid the most common mistakes developers make when working with the hook system.

What Are WordPress Hooks?

At its core, a WordPress hook is a specific point in the execution of WordPress code where you can insert your own custom functionality without modifying the original source files. Think of hooks as designated “connection points” built into WordPress that say, “If anyone wants to do something here, now is the time.”

WordPress has hundreds of built-in hooks spread throughout its codebase. Every time a page loads, dozens of these hooks fire in a specific order, giving you the opportunity to run your own code at precisely the right moment. This architecture is what makes WordPress so extensible. You do not need to hack core files to change behavior. Instead, you attach your code to the appropriate hook, and WordPress calls your code at the right time.

There are exactly two types of hooks in WordPress:

  • Actions — hooks that let you execute code at a specific point in the WordPress lifecycle. Actions do something.
  • Filters — hooks that let you modify a piece of data before it is used or displayed. Filters change something.

Both types use the same underlying system (the WP_Hook class), but their intent and usage patterns are different. Understanding that distinction is the key to mastering WordPress development.

The Assembly Line Analogy: Actions vs. Filters

Here is a practical analogy that makes the difference between actions and filters immediately clear. Imagine a car factory with an assembly line.

Actions Are Workers Along the Assembly Line

As the car moves down the line, workers stand at various stations. Each worker performs a task: one installs the engine, another attaches the doors, a third paints the body. These workers do not modify the assembly line itself. They do not change the car that was already partially built by someone else. They simply perform their job when the car reaches their station.

In WordPress, actions work the same way. When WordPress reaches a certain point in its execution (like loading the header, initializing the admin, or saving a post), it announces that moment by firing an action hook. Any functions you have attached to that hook will execute at that point. Your function does its work — sending an email, logging data, enqueuing a script — and then WordPress continues on its way.

Filters Are Quality Inspectors Who Modify the Product

Now imagine a different set of workers on the same assembly line. These workers receive a specific component — say, a dashboard panel — inspect it, potentially modify it (adding a gauge, changing the color), and then pass the modified component back so it can be installed in the car.

Filters work the same way in WordPress. WordPress passes a piece of data through a filter hook, and any functions attached to that filter receive the data, optionally modify it, and must return it (whether modified or not). The returned value is what WordPress uses going forward.

This distinction — actions execute code, filters modify data — is fundamental. If you remember nothing else from this article, remember that.

How add_action() Works

The add_action() function is how you tell WordPress: “When this specific event happens, run my function.” Here is the function signature:

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

Let us break down each parameter:

  • $hook_name — The name of the action hook you want to attach to. Examples include init, wp_head, save_post, admin_init.
  • $callback — The function that should run when the hook fires. This can be a function name (string), an anonymous function, or an array referencing a class method.
  • $priority — A number that determines when your function runs relative to other functions attached to the same hook. Lower numbers run earlier. Default is 10.
  • $accepted_args — How many arguments your callback function expects to receive. Default is 1.

Basic add_action() Examples

Here is the simplest possible example. We want to add a meta tag to the <head> section of every page:

function wppioneer_add_meta_tag() {
    echo '<meta name="theme-color" content="#2563eb">';
}
add_action( 'wp_head', 'wppioneer_add_meta_tag' );

When WordPress processes the wp_head action (which fires inside the <head> tag of your theme), it will call our wppioneer_add_meta_tag() function, which outputs the meta tag.

Here is another common example — enqueuing scripts and styles the correct way:

function wppioneer_enqueue_assets() {
    wp_enqueue_style(
        'wppioneer-main',
        get_stylesheet_directory_uri() . '/assets/css/main.css',
        array(),
        '1.0.0'
    );

    wp_enqueue_script(
        'wppioneer-app',
        get_stylesheet_directory_uri() . '/assets/js/app.js',
        array( 'jquery' ),
        '1.0.0',
        true
    );
}
add_action( 'wp_enqueue_scripts', 'wppioneer_enqueue_assets' );

Notice that we attach to the wp_enqueue_scripts action, not wp_head. This is an important distinction: WordPress provides specific hooks for specific tasks, and using the right hook matters.

Using add_action() with Class Methods

If your code is organized into classes (as it should be for plugins), you attach methods using an array:

class WPPioneer_Custom_Feature {

    public function __construct() {
        add_action( 'init', array( $this, 'register_post_type' ) );
        add_action( 'admin_menu', array( $this, 'add_settings_page' ) );
    }

    public function register_post_type() {
        register_post_type( 'wppioneer_project', array(
            'labels' => array(
                'name'          => 'Projects',
                'singular_name' => 'Project',
            ),
            'public'      => true,
            'has_archive'  => true,
            'show_in_rest' => true,
            'supports'     => array( 'title', 'editor', 'thumbnail' ),
        ) );
    }

    public function add_settings_page() {
        add_options_page(
            'WPPioneer Settings',
            'WPPioneer',
            'manage_options',
            'wppioneer-settings',
            array( $this, 'render_settings_page' )
        );
    }

    public function render_settings_page() {
        echo '<div class="wrap"><h1>WPPioneer Settings</h1></div>';
    }
}

new WPPioneer_Custom_Feature();

If you are working with custom post types, hooking into init is the standard approach, and the class-based pattern above keeps your code organized and testable.

How add_filter() Works

The add_filter() function works similarly to add_action(), but with one critical difference: your callback must return a value. Here is the function signature:

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

The parameters are identical to add_action(). The difference is entirely in how your callback behaves.

Basic add_filter() Examples

One of the most common filters in WordPress is the_content, which lets you modify post content before it is displayed:

function wppioneer_add_cta_after_content( $content ) {
    if ( is_single() && in_the_loop() && is_main_query() ) {
        $cta = '<div class="wppioneer-cta" style="padding: 20px; background: #f0f4ff; border-left: 4px solid #2563eb; margin-top: 30px;">';
        $cta .= '<p><strong>Enjoyed this article?</strong> Subscribe to our newsletter for weekly WordPress tips.</p>';
        $cta .= '</div>';

        $content .= $cta;
    }

    return $content;
}
add_filter( 'the_content', 'wppioneer_add_cta_after_content' );

Notice the critical pattern here: the function receives $content, modifies it (by appending a CTA box), and then returns the modified $content. If you forget to return the value, the post content will disappear entirely — a very common beginner mistake.

Here is another practical example — modifying the excerpt length:

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

And customizing the “Read More” text:

function wppioneer_custom_read_more( $more ) {
    return '… <a href="' . esc_url( get_permalink() ) . '" class="read-more-link">Continue Reading</a>';
}
add_filter( 'excerpt_more', 'wppioneer_custom_read_more' );

Filters with Multiple Arguments

Some filters pass more than one argument. When that is the case, you need to specify the number of accepted arguments in the fourth parameter of add_filter():

function wppioneer_modify_query_args( $query_args, $post_type, $context ) {
    if ( 'wppioneer_project' === $post_type && 'archive' === $context ) {
        $query_args['posts_per_page'] = 12;
        $query_args['orderby']        = 'menu_order';
        $query_args['order']          = 'ASC';
    }

    return $query_args;
}
add_filter( 'wppioneer_archive_query_args', 'wppioneer_modify_query_args', 10, 3 );

The 10 is the priority (default), and 3 tells WordPress that this callback expects three arguments. If you do not specify 3, your function would only receive the first argument.

Behind the Scenes: do_action() and apply_filters()

So far, we have looked at how to attach code to hooks. But how do those hooks get triggered in the first place? That is where do_action() and apply_filters() come in.

do_action()

The do_action() function fires an action hook. WordPress core uses it extensively, and you can use it to create your own action hooks in themes and plugins:

do_action( string $hook_name, mixed ...$args );

When do_action() is called, WordPress looks up all functions that have been registered to that hook via add_action() and calls them in order of priority. Here is what happens internally:

  1. WordPress checks if any callbacks are registered for the given hook name.
  2. It sorts callbacks by priority (ascending).
  3. It calls each callback in order, passing any additional arguments.
  4. It does not collect or use any return values from the callbacks.

Here are some of the most important do_action() calls in WordPress core and the approximate order in which they fire on a typical front-end page load:

do_action( 'muplugins_loaded' );    // After MU plugins load
do_action( 'plugins_loaded' );       // After all plugins load
do_action( 'after_setup_theme' );    // After the active theme loads
do_action( 'init' );                 // WordPress is fully initialized
do_action( 'wp_loaded' );            // After WordPress is fully loaded
do_action( 'template_redirect' );    // Before the template is chosen
do_action( 'wp_head' );              // Inside the <head> tag
do_action( 'wp_footer' );            // Before </body>
do_action( 'shutdown' );             // After PHP finishes execution

apply_filters()

The apply_filters() function fires a filter hook. Unlike do_action(), it passes a value through all registered callbacks and returns the final result:

$value = apply_filters( string $hook_name, mixed $value, mixed ...$args );

Here is what happens when apply_filters() is called:

  1. WordPress checks if any callbacks are registered for the given hook name.
  2. If not, it returns the original value unchanged.
  3. If callbacks exist, it sorts them by priority.
  4. It passes the value to the first callback, which returns a (possibly modified) value.
  5. That returned value is passed to the next callback, and so on.
  6. The final returned value from the last callback is what apply_filters() returns.

This is the piping mechanism at the heart of WordPress filters. Each callback in the chain receives the output of the previous callback, allowing multiple plugins and themes to successively modify the same piece of data.

Here is an example of how WordPress core uses apply_filters() for post titles:

// Inside WordPress core (simplified)
function the_title( $before = '', $after = '', $echo = true ) {
    $title = get_the_title();
    $title = apply_filters( 'the_title', $title, get_the_ID() );

    // ... output logic
}

Because WordPress runs the title through apply_filters( 'the_title'... ), any plugin or theme can modify post titles without editing WordPress core.

Hook Priority and Execution Order

The priority parameter is more important than many developers realize. It determines the order in which callbacks execute on a given hook. Lower numbers mean earlier execution.

function wppioneer_early_action() {
    // This runs first.
    error_log( 'Priority 5 ran' );
}
add_action( 'init', 'wppioneer_early_action', 5 );

function wppioneer_default_action() {
    // This runs second (default priority).
    error_log( 'Priority 10 ran' );
}
add_action( 'init', 'wppioneer_default_action' );

function wppioneer_late_action() {
    // This runs third.
    error_log( 'Priority 20 ran' );
}
add_action( 'init', 'wppioneer_late_action', 20 );

function wppioneer_very_late_action() {
    // This runs last.
    error_log( 'Priority 999 ran' );
}
add_action( 'init', 'wppioneer_very_late_action', 999 );

The output in the debug log would be:

Priority 5 ran
Priority 10 ran
Priority 20 ran
Priority 999 ran

Why Priority Matters for Filters

Priority is especially significant with filters because each callback in the chain modifies the value for all subsequent callbacks. If you want to ensure your filter runs after all other plugins have modified a value (giving you the “last word”), use a high priority number:

// Plugin A modifies the title at default priority.
function plugin_a_modify_title( $title ) {
    return $title . ' | Plugin A';
}
add_filter( 'the_title', 'plugin_a_modify_title' );

// Plugin B modifies the title at priority 20.
function plugin_b_modify_title( $title ) {
    return $title . ' | Plugin B';
}
add_filter( 'the_title', 'plugin_b_modify_title', 20 );

// Your code runs last at priority 999.
function wppioneer_final_title( $title ) {
    // At this point, $title is "Original Title | Plugin A | Plugin B"
    return $title . ' - WPPioneer';
}
add_filter( 'the_title', 'wppioneer_final_title', 999 );

The final title would be: “Original Title | Plugin A | Plugin B – WPPioneer”

When Two Callbacks Share the Same Priority

If two callbacks have the same priority, they execute in the order they were added. This is usually the order in which plugins are loaded, which in turn depends on alphabetical order of plugin file names. This is not something you should rely on — if execution order matters, use different priority values.

Removing Hooks with remove_action() and remove_filter()

Sometimes you need to remove a hook that was added by WordPress core or another plugin. This is done with remove_action() and remove_filter():

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

To successfully remove a hook, you must match all three parameters — the hook name, the exact callback reference, and the priority. If the original hook was added with priority 15 and you try to remove it with the default priority of 10, the removal will silently fail.

Removing a Named Function

// WordPress core adds this in wp-includes/default-filters.php:
// add_filter( 'the_content', 'wpautop' );

// Remove the automatic paragraph tags from post content.
remove_filter( 'the_content', 'wpautop' );

This needs to be called after the original add_filter() has run. A common pattern is to remove it on the after_setup_theme or init hook:

function wppioneer_remove_wpautop() {
    remove_filter( 'the_content', 'wpautop' );
}
add_action( 'init', 'wppioneer_remove_wpautop' );

Removing a Class Method

This is where things get tricky. If a plugin adds a hook using a class method, you need a reference to the exact same object instance to remove it:

// If the plugin does this:
class Some_Plugin {
    public function __construct() {
        add_action( 'wp_footer', array( $this, 'render_badge' ) );
    }

    public function render_badge() {
        echo '<div class="plugin-badge">Powered by Some Plugin</div>';
    }
}
$some_plugin_instance = new Some_Plugin();

// You can remove it IF you have access to the same instance:
remove_action( 'wp_footer', array( $some_plugin_instance, 'render_badge' ) );

If the plugin does not store its instance in a global variable, removing the hook becomes significantly harder. You may need to access the global $wp_filter array directly, which is fragile and not recommended for production code unless absolutely necessary.

Removing All Callbacks from a Hook

In rare cases, you may want to remove every callback from a specific hook:

// Remove all callbacks from 'wp_head' at all priorities.
remove_all_actions( 'wp_head' );

// Remove all callbacks from 'the_content' at priority 10 only.
remove_all_filters( 'the_content', 10 );

Use these functions sparingly. Removing all callbacks from a hook like wp_head will break scripts, styles, meta tags, and many other critical elements.

Real-World Examples

Let us walk through several practical examples that demonstrate common use cases for WordPress hooks. These are patterns you will use regularly in real projects.

Example 1: Modifying the_content to Add a Table of Contents

function wppioneer_auto_toc( $content ) {
    if ( ! is_single() || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    // Find all h2 and h3 headings in the content.
    preg_match_all(
        '/<h([23])[^>]*>(.*?)<\/h[23]>/i',
        $content,
        $matches,
        PREG_SET_ORDER
    );

    if ( count( $matches ) < 3 ) {
        return $content; // Not enough headings to warrant a TOC.
    }

    $toc = '<div class="wppioneer-toc">';
    $toc .= '<h3>Table of Contents</h3><ul>';

    foreach ( $matches as $index => $match ) {
        $level    = $match[1];
        $text     = wp_strip_all_tags( $match[2] );
        $slug     = sanitize_title( $text );
        $indent   = ( '3' === $level ) ? ' style="margin-left: 20px;"' : '';

        $toc .= '<li' . $indent . '><a href="#' . esc_attr( $slug ) . '">' . esc_html( $text ) . '</a></li>';

        // Add an id attribute to the heading in the content.
        $heading_with_id = sprintf(
            '<h%s id="%s">%s</h%s>',
            $level,
            esc_attr( $slug ),
            $match[2],
            $level
        );
        $content = str_replace( $match[0], $heading_with_id, $content );
    }

    $toc .= '</ul></div>';

    return $toc . $content;
}
add_filter( 'the_content', 'wppioneer_auto_toc', 5 );

Note the priority of 5. We run this early so that other filters that process the_content (like wpautop at priority 10) see the headings with their new IDs.

Example 2: Custom Login Redirect Based on User Role

function wppioneer_login_redirect( $redirect_to, $requested_redirect_to, $user ) {
    if ( is_wp_error( $user ) ) {
        return $redirect_to;
    }

    $roles = $user->roles;

    if ( in_array( 'administrator', $roles, true ) ) {
        return admin_url( 'index.php' );
    }

    if ( in_array( 'editor', $roles, true ) ) {
        return admin_url( 'edit.php' );
    }

    if ( in_array( 'subscriber', $roles, true ) ) {
        return home_url( '/my-account/' );
    }

    return $redirect_to;
}
add_filter( 'login_redirect', 'wppioneer_login_redirect', 10, 3 );

This filter receives three arguments (the redirect URL, the originally requested redirect, and the user object), so we specify 3 as the accepted arguments count. The function checks the user role and returns a different redirect URL for each role.

Example 3: Modifying the Main Query with pre_get_posts

The pre_get_posts action is one of the most powerful hooks in WordPress. It lets you modify the main query before it runs:

function wppioneer_modify_main_query( $query ) {
    // Only modify the main query on the front end.
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }

    // Exclude a specific category from the blog page.
    if ( $query->is_home() ) {
        $query->set( 'cat', '-5' ); // Exclude category ID 5.
    }

    // Change posts per page on custom post type archives.
    if ( $query->is_post_type_archive( 'wppioneer_project' ) ) {
        $query->set( 'posts_per_page', 12 );
        $query->set( 'orderby', 'title' );
        $query->set( 'order', 'ASC' );
    }

    // Add custom post types to the main search.
    if ( $query->is_search() ) {
        $query->set( 'post_type', array( 'post', 'page', 'wppioneer_project' ) );
    }
}
add_action( 'pre_get_posts', 'wppioneer_modify_main_query' );

Notice that pre_get_posts is technically an action (not a filter), even though it modifies data. That is because it passes the query object by reference. You modify the object directly rather than returning a value. This is an important nuance to understand.

Example 4: Adding Open Graph Tags to wp_head

function wppioneer_open_graph_tags() {
    if ( ! is_singular() ) {
        return;
    }

    global $post;
    setup_postdata( $post );

    $title       = get_the_title();
    $description = has_excerpt() ? get_the_excerpt() : wp_trim_words( get_the_content(), 30 );
    $url         = get_permalink();
    $image       = get_the_post_thumbnail_url( $post->ID, 'large' );
    $site_name   = get_bloginfo( 'name' );

    echo '<meta property="og:type" content="article">' . "\n";
    echo '<meta property="og:title" content="' . esc_attr( $title ) . '">' . "\n";
    echo '<meta property="og:description" content="' . esc_attr( $description ) . '">' . "\n";
    echo '<meta property="og:url" content="' . esc_url( $url ) . '">' . "\n";
    echo '<meta property="og:site_name" content="' . esc_attr( $site_name ) . '">' . "\n";

    if ( $image ) {
        echo '<meta property="og:image" content="' . esc_url( $image ) . '">' . "\n";
    }

    wp_reset_postdata();
}
add_action( 'wp_head', 'wppioneer_open_graph_tags', 5 );

Example 5: Modifying WooCommerce Product Tabs

function wppioneer_modify_product_tabs( $tabs ) {
    // Remove the reviews tab.
    unset( $tabs['reviews'] );

    // Rename the description tab.
    if ( isset( $tabs['description'] ) ) {
        $tabs['description']['title'] = 'Product Details';
    }

    // Add a custom tab.
    $tabs['wppioneer_specs'] = array(
        'title'    => 'Specifications',
        'priority' => 25,
        'callback' => 'wppioneer_specs_tab_content',
    );

    return $tabs;
}
add_filter( 'woocommerce_product_tabs', 'wppioneer_modify_product_tabs' );

function wppioneer_specs_tab_content() {
    global $product;
    $specs = get_post_meta( $product->get_id(), '_wppioneer_specifications', true );

    if ( $specs ) {
        echo '<h2>Technical Specifications</h2>';
        echo wp_kses_post( $specs );
    }
}

Creating Your Own Hooks

Creating custom hooks is what separates decent WordPress code from truly extensible WordPress code. When you build a plugin or theme, adding your own hooks at key points allows other developers (or future you) to modify behavior without editing your source code.

Creating Custom Actions

Use do_action() to create action hooks at meaningful points in your code:

class WPPioneer_Order_Processor {

    public function process_order( $order_id ) {
        $order_data = $this->validate_order( $order_id );

        // Let other code run before processing begins.
        do_action( 'wppioneer_before_order_process', $order_id, $order_data );

        $result = $this->execute_order( $order_data );

        if ( $result ) {
            // Let other code run after a successful order.
            do_action( 'wppioneer_order_processed', $order_id, $order_data, $result );
        } else {
            // Let other code run after a failed order.
            do_action( 'wppioneer_order_failed', $order_id, $order_data );
        }

        // Always fire a completion hook regardless of outcome.
        do_action( 'wppioneer_after_order_process', $order_id, $result );
    }
}

Now other developers can hook into your order processing flow:

// Send a notification email when an order is processed.
add_action( 'wppioneer_order_processed', function( $order_id, $order_data, $result ) {
    wp_mail(
        get_option( 'admin_email' ),
        'New Order Processed: #' . $order_id,
        'Order details: ' . wp_json_encode( $order_data )
    );
}, 10, 3 );

// Log failed orders for debugging.
add_action( 'wppioneer_order_failed', function( $order_id, $order_data ) {
    error_log( sprintf(
        'Order #%d failed. Data: %s',
        $order_id,
        wp_json_encode( $order_data )
    ) );
}, 10, 2 );

Creating Custom Filters

Use apply_filters() to create filter hooks wherever you want to let others modify a value:

class WPPioneer_Email_Sender {

    public function send_welcome_email( $user_id ) {
        $user = get_userdata( $user_id );

        // Let others modify the email subject.
        $subject = apply_filters(
            'wppioneer_welcome_email_subject',
            'Welcome to ' . get_bloginfo( 'name' ),
            $user
        );

        // Let others modify the email body.
        $body = apply_filters(
            'wppioneer_welcome_email_body',
            $this->get_default_welcome_body( $user ),
            $user
        );

        // Let others modify the email headers.
        $headers = apply_filters(
            'wppioneer_welcome_email_headers',
            array( 'Content-Type: text/html; charset=UTF-8' ),
            $user
        );

        wp_mail( $user->user_email, $subject, $body, $headers );
    }

    private function get_default_welcome_body( $user ) {
        return sprintf(
            '<p>Hello %s, welcome aboard.</p>',
            esc_html( $user->display_name )
        );
    }
}

Now anyone can customize the welcome email without touching your plugin code:

// Customize the welcome email subject.
add_filter( 'wppioneer_welcome_email_subject', function( $subject, $user ) {
    return sprintf( 'Hey %s, welcome to the community!', $user->first_name );
}, 10, 2 );

// Add a signature to the welcome email body.
add_filter( 'wppioneer_welcome_email_body', function( $body, $user ) {
    $body .= '<p>Best regards,<br>The Team</p>';
    return $body;
}, 10, 2 );

Best Practices for Custom Hooks

  • Prefix your hook names with your plugin or theme slug to avoid collisions: wppioneer_before_render, not before_render.
  • Document your hooks. Use PHPDoc comments above every do_action() and apply_filters() call to describe what the hook does, what parameters are passed, and what the expected return type is for filters.
  • Pass useful context. Give your hooks enough arguments that consumers can make informed decisions about what to do.
  • Keep hook names consistent. Use a pattern like prefix_before_action, prefix_after_action, prefix_noun_modifier.

For deeper guidance on structuring plugin code with proper hooks, the WordPress Plugin Handbook: Hooks is an essential reference.

Common Mistakes and How to Avoid Them

After working with WordPress hooks for years and reviewing code from dozens of plugins, these are the mistakes that come up most frequently. Avoiding them will save you hours of debugging.

Mistake 1: Forgetting to Return the Value in a Filter

This is by far the most common hook-related bug. If your filter callback does not return a value, the filtered data becomes null, which typically causes content to disappear or break.

// WRONG: This will make all post titles disappear.
function broken_title_filter( $title ) {
    // Do something with the title but forget to return it.
    $title = strtoupper( $title );
    // Missing: return $title;
}
add_filter( 'the_title', 'broken_title_filter' );

// CORRECT: Always return the value.
function working_title_filter( $title ) {
    return strtoupper( $title );
}
add_filter( 'the_title', 'working_title_filter' );

Mistake 2: Hooking Too Early or Too Late

If you try to use a WordPress function before it is available, you will get a fatal error. The most common version of this is calling functions like get_option() or is_admin() before WordPress has fully loaded.

// WRONG: This runs at file inclusion time, too early.
$option = get_option( 'my_setting' );

// CORRECT: Wait for the appropriate hook.
function wppioneer_init_settings() {
    $option = get_option( 'my_setting' );
    // Now use $option.
}
add_action( 'init', 'wppioneer_init_settings' );

Mistake 3: Wrong Priority When Removing Hooks

As mentioned earlier, you must match the priority when removing a hook. This catches people off guard constantly:

// Some plugin adds a hook at priority 15.
add_action( 'wp_footer', 'some_plugin_footer_output', 15 );

// WRONG: This will NOT remove the hook because priorities do not match.
remove_action( 'wp_footer', 'some_plugin_footer_output' ); // Default priority: 10.

// CORRECT: Match the priority.
remove_action( 'wp_footer', 'some_plugin_footer_output', 15 );

Mistake 4: Using the Wrong Hook for the Job

This is a design mistake rather than a syntax error. Common examples include:

  • Enqueueing scripts with wp_head instead of wp_enqueue_scripts
  • Registering post types on admin_init instead of init
  • Running database queries on plugins_loaded (too early for some functions)
  • Using init for things that should happen on admin_init (or vice versa)

When in doubt, consult the WordPress Developer Reference: Hooks to find the right hook for your use case.

Mistake 5: Not Checking Context in Callbacks

A callback attached to a hook fires every time that hook fires. If you want your code to run only on single posts, only in the admin, or only for a specific post type, you need to check that context inside your callback:

// WRONG: This runs on every page, including archives, search results, and the admin.
function wppioneer_single_only_cta( $content ) {
    $content .= '<div class="cta">Subscribe!</div>';
    return $content;
}
add_filter( 'the_content', 'wppioneer_single_only_cta' );

// CORRECT: Check context before modifying.
function wppioneer_single_only_cta( $content ) {
    if ( is_single() && in_the_loop() && is_main_query() ) {
        $content .= '<div class="cta">Subscribe!</div>';
    }
    return $content;
}
add_filter( 'the_content', 'wppioneer_single_only_cta' );

Mistake 6: Anonymous Functions and Removal

If you use an anonymous function (closure) as your callback, you cannot remove it later:

// This cannot be removed later because there is no reference to the closure.
add_action( 'wp_footer', function() {
    echo '<!-- Added by anonymous function -->';
} );

// BETTER: Use a named function if removal might be needed.
function wppioneer_footer_comment() {
    echo '<!-- Added by wppioneer -->';
}
add_action( 'wp_footer', 'wppioneer_footer_comment' );

Anonymous functions are fine for quick, self-contained code that will never need to be removed. But for anything in a plugin that other developers might want to override, always use named functions or class methods.

Mistake 7: Infinite Loops with Filters

If your filter callback calls a function that triggers the same filter, you will create an infinite loop:

// DANGEROUS: get_the_title() internally applies 'the_title' filter, creating a loop.
function wppioneer_recursive_title( $title ) {
    $other_title = get_the_title( 123 ); // This triggers 'the_title' again.
    return $title . ' - ' . $other_title;
}
add_filter( 'the_title', 'wppioneer_recursive_title' );

// SAFE: Remove the filter before calling the function that triggers it, then re-add.
function wppioneer_safe_title( $title, $post_id ) {
    remove_filter( 'the_title', 'wppioneer_safe_title', 10 );
    $other_title = get_the_title( 123 );
    add_filter( 'the_title', 'wppioneer_safe_title', 10, 2 );

    return $title . ' - ' . $other_title;
}
add_filter( 'the_title', 'wppioneer_safe_title', 10, 2 );

Debugging Hooks

When hooks are not behaving as expected, you need strategies to figure out what is happening. Here are the most effective approaches.

Method 1: List All Callbacks on a Hook

You can inspect the global $wp_filter array to see every callback registered on a specific hook:

function wppioneer_debug_hook( $hook_name ) {
    global $wp_filter;

    if ( ! isset( $wp_filter[ $hook_name ] ) ) {
        error_log( "No callbacks registered for hook: {$hook_name}" );
        return;
    }

    error_log( "Callbacks for hook '{$hook_name}':" );

    foreach ( $wp_filter[ $hook_name ]->callbacks as $priority => $callbacks ) {
        foreach ( $callbacks as $id => $callback_data ) {
            $callback = $callback_data['function'];

            if ( is_array( $callback ) ) {
                $class  = is_object( $callback[0] ) ? get_class( $callback[0] ) : $callback[0];
                $method = $callback[1];
                error_log( "  Priority {$priority}: {$class}::{$method}" );
            } elseif ( is_string( $callback ) ) {
                error_log( "  Priority {$priority}: {$callback}()" );
            } else {
                error_log( "  Priority {$priority}: Closure" );
            }
        }
    }
}

// Usage: Call this after all plugins have loaded.
add_action( 'wp_loaded', function() {
    wppioneer_debug_hook( 'the_content' );
} );

Method 2: Track When a Hook Fires

function wppioneer_track_hook_execution( $hook_name ) {
    add_action( $hook_name, function() use ( $hook_name ) {
        error_log( sprintf(
            '[HOOK FIRED] %s at %s',
            $hook_name,
            current_time( 'H:i:s' )
        ) );
    }, 0 );
}

// Track multiple hooks.
wppioneer_track_hook_execution( 'init' );
wppioneer_track_hook_execution( 'wp_loaded' );
wppioneer_track_hook_execution( 'template_redirect' );
wppioneer_track_hook_execution( 'wp_head' );

Method 3: Using the Query Monitor Plugin

For a visual approach, the Query Monitor plugin is invaluable. It shows you every hook that fires during a page load, which callbacks are registered, their priorities, and how long each takes to execute. If you are serious about WordPress development, Query Monitor should be one of the first plugins you install on any development site.

Method 4: Temporary Debugging with did_action() and has_filter()

WordPress provides helper functions to check hook state:

// Check if an action has fired (and how many times).
if ( did_action( 'init' ) ) {
    // 'init' has already fired.
    error_log( 'init has fired ' . did_action( 'init' ) . ' time(s)' );
}

// Check if a callback is registered on a filter.
if ( has_filter( 'the_content', 'wpautop' ) ) {
    error_log( 'wpautop is registered on the_content at priority: ' . has_filter( 'the_content', 'wpautop' ) );
}

// has_filter() returns the priority number if found, or false if not.
$priority = has_filter( 'the_content', 'wptexturize' );
if ( false !== $priority ) {
    error_log( "wptexturize runs at priority: {$priority}" );
}

Advanced Hook Patterns

Once you are comfortable with the basics, these patterns will help you write more sophisticated and maintainable code.

Conditional Hook Registration

Sometimes you only want to register a hook if certain conditions are met. Do this inside an appropriate early hook:

function wppioneer_conditional_hooks() {
    // Only add the filter for logged-in users.
    if ( is_user_logged_in() ) {
        add_filter( 'the_content', 'wppioneer_member_content_notice' );
    }

    // Only modify the query on the front end.
    if ( ! is_admin() ) {
        add_action( 'pre_get_posts', 'wppioneer_modify_main_query' );
    }
}
add_action( 'init', 'wppioneer_conditional_hooks' );

Using do_action_ref_array() for Complex Data

When you need to pass many arguments to an action hook, do_action_ref_array() accepts an array of arguments:

$args = array(
    'user_id'    => $user_id,
    'order_id'   => $order_id,
    'items'      => $cart_items,
    'total'      => $total,
    'currency'   => $currency,
);

do_action_ref_array( 'wppioneer_checkout_complete', $args );

One-Time Hook Execution

If you need a callback to run only once, even if the hook fires multiple times, remove it after it runs:

function wppioneer_run_once() {
    // Do something that should only happen once.
    error_log( 'This only runs once.' );

    // Remove itself so it does not run again.
    remove_action( 'save_post', 'wppioneer_run_once' );
}
add_action( 'save_post', 'wppioneer_run_once' );

The current_filter() Function

Inside a callback, you can determine which hook triggered it using current_filter(). This is useful when the same callback is attached to multiple hooks:

function wppioneer_shared_callback( $value ) {
    $current_hook = current_filter();

    if ( 'the_title' === $current_hook ) {
        return strtoupper( $value );
    }

    if ( 'the_excerpt' === $current_hook ) {
        return wp_trim_words( $value, 20 );
    }

    return $value;
}
add_filter( 'the_title', 'wppioneer_shared_callback' );
add_filter( 'the_excerpt', 'wppioneer_shared_callback' );

Hook Execution Order on a Typical Page Load

Understanding the order in which WordPress hooks fire gives you the confidence to attach your code to the right hook. Here is a simplified sequence for a standard front-end page load:

  1. muplugins_loaded — MU plugins are loaded
  2. registered_taxonomy — After each taxonomy is registered
  3. registered_post_type — After each post type is registered
  4. plugins_loaded — All active plugins are loaded
  5. sanitize_comment_cookies
  6. setup_theme — Before the theme is loaded
  7. after_setup_theme — After the theme’s functions.php runs
  8. init — WordPress core is initialized
  9. widgets_init — Widget areas are registered
  10. wp_loaded — WordPress and all plugins/themes are fully loaded
  11. parse_request — The request is parsed
  12. send_headers — HTTP headers are sent
  13. pre_get_posts — Before the main query runs
  14. wp — The WordPress environment is set up
  15. template_redirect — Before the template is loaded
  16. wp_enqueue_scripts — Scripts and styles should be enqueued here
  17. wp_head — Inside the <head> tag
  18. the_post — Inside The Loop, when each post is set up
  19. wp_footer — Before the closing </body> tag
  20. shutdown — After PHP has finished execution

Knowing this sequence helps you choose the earliest hook where the functionality you need is available. For instance, if you need to check the current post type, you cannot do that on init (the query has not run yet), but you can on template_redirect or later.

Performance Considerations

Hooks are lightweight, but they are not free. Here are some performance tips to keep in mind:

  • Avoid heavy processing in frequently-fired hooks. The the_content filter fires for every post in a loop. If you are running a regex on the full content, that adds up on archive pages with 20 or more posts.
  • Use early returns. Check your conditions at the top of the callback and return immediately if they are not met. Do not run complex logic only to discover at the end that the callback should not have executed.
  • Cache results when possible. If your hook callback queries the database, consider storing the result in a static variable or transient so subsequent calls on the same page load do not hit the database again.
  • Do not add hooks inside The Loop. This is a common mistake where add_action() or add_filter() is called inside a while loop, registering the callback multiple times.
// WRONG: This adds the filter once per post in the loop.
while ( have_posts() ) {
    the_post();
    add_filter( 'the_content', 'my_content_filter' ); // Registered N times!
}

// CORRECT: Add the filter once, outside the loop.
add_filter( 'the_content', 'my_content_filter' );
while ( have_posts() ) {
    the_post();
    // ... display post
}

If you are building a plugin and want to ensure your PHP code is compatible with modern PHP versions and follows best practices, take a look at our guide on PHP 8.x compatibility for plugin developers.

Quick Reference Cheat Sheet

Here is a condensed reference you can bookmark for daily use:

Registering Hooks

// Attach to an action hook.
add_action( 'hook_name', 'callback_function', $priority, $accepted_args );

// Attach to a filter hook.
add_filter( 'hook_name', 'callback_function', $priority, $accepted_args );

Triggering Hooks

// Fire an action hook.
do_action( 'hook_name', $arg1, $arg2 );

// Fire a filter hook (returns the filtered value).
$value = apply_filters( 'hook_name', $value, $arg1, $arg2 );

Removing Hooks

// Remove a specific callback from an action.
remove_action( 'hook_name', 'callback_function', $priority );

// Remove a specific callback from a filter.
remove_filter( 'hook_name', 'callback_function', $priority );

// Remove all callbacks from a hook.
remove_all_actions( 'hook_name' );
remove_all_filters( 'hook_name' );

Checking Hook State

// Has this action fired? Returns the number of times it has fired.
did_action( 'hook_name' );

// Is a callback registered on this filter? Returns priority or false.
has_filter( 'hook_name', 'callback_function' );

// What hook is currently being executed?
current_filter();

Wrapping Up

WordPress hooks are the backbone of WordPress extensibility. They are the reason thousands of plugins can coexist on a single site, each modifying behavior and data without ever touching another plugin’s code. Understanding the distinction between actions (do something) and filters (change something), knowing how priority controls execution order, and being disciplined about always returning values in filters will put you ahead of a large percentage of WordPress developers.

The key takeaways from this guide are:

  • Actions execute code at specific points in the WordPress lifecycle. They do not return values.
  • Filters modify data by receiving a value, optionally changing it, and returning it. Always return the value.
  • add_action() and add_filter() register your callbacks. do_action() and apply_filters() trigger them.
  • Priority controls execution order. Lower numbers run first. Default is 10.
  • When removing hooks, you must match the callback reference and priority exactly.
  • Create your own hooks in plugins and themes to make your code extensible.
  • Always check context (is_single, is_admin, is_main_query) inside your callbacks.
  • Use Query Monitor and the debugging techniques above when hooks behave unexpectedly.

Start by identifying the hooks you already use (you are almost certainly using wp_enqueue_scripts and init at minimum) and work outward from there. The more hooks you learn, the more precisely you can control WordPress behavior, and the cleaner your code will become.

Visited 3 times, 1 visit(s) today

Last modified: April 2, 2026

Close