Skip to main content

πŸͺ Action and Filter Hooks

Hooks are the beating heart of WordPress extensibility. They let your plugin plug into hundreds of moments in WordPress's execution β€” to do something (actions) or to change something (filters) β€” all without editing a single line of core.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain WordPress's event-driven hook system and how core "fires" hooks
  • Distinguish actions (do something) from filters (change something) and use each correctly
  • Register callbacks with add_action / add_filter, controlling priority and accepted arguments
  • Create your own custom hooks to make a plugin extensible
  • Remove, inspect, and debug hooks confidently

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Add a "reading time" estimate to posts using a filter, plus your own custom action.

In This Lesson

The Hook System

WordPress is event-driven. As it builds a page, core execution reaches dozens of predefined moments β€” loading plugins, initialising, printing the <head>, rendering content, closing the page. At each moment it announces "I've reached this point" by firing a hook. Your plugin registers callbacks that run when a hook fires.

πŸ’‘ Analogy: Picture WordPress as a train line with fixed stations (the hooks). Your plugin is a new service that can pull in at any station to pick up passengers (read data) or drop off cargo (add functionality). You never re-lay the tracks β€” you just use the stations that are already there.
flowchart TB A[WordPress core executes] -->|reaches a hook point| B{Any callbacks
registered?} B -->|No| C[Continue execution] B -->|Yes| D[Run each callback
in priority order] D --> C

If nothing is registered for a hook, core simply carries on β€” hooks are zero-cost when unused. That is what makes them the safe, universal extension mechanism for the whole ecosystem.

Actions vs. Filters

There are exactly two kinds of hooks, and the difference is simple but crucial: actions do something; filters change something and return it.

Actions versus filters An action receives a signal and performs a side effect with no return value. A filter receives a value, transforms it, and must return the modified value. Action "do something" signal side effect nothing returned Filter "change something" value in modify value out (returned)
Figure 1 β€” An action performs a side effect and returns nothing. A filter takes a value, transforms it, and must return the result.
Action hooksFilter hooks
PurposePerform an operation at a point in timeModify a piece of data before it's used
MindsetAn event you respond toA value that passes through you
Return valueNoneMust return the (modified) value
Register withadd_action()add_filter()

An action

<?php
function my_plugin_custom_banner(): void {
    echo '<div class="site-banner">Special announcement!</div>';
}
add_action( 'wp_body_open', 'my_plugin_custom_banner' );

A filter

<?php
function my_plugin_star_title( string $title ): string {
    return 'β˜… ' . $title . ' β˜…';
}
add_filter( 'the_title', 'my_plugin_star_title' );
🍳 Kitchen analogy: If WordPress is a restaurant kitchen, an action is a signal that lets you add a whole new dish to the pass, while a filter is your chance to adjust the seasoning of a dish before it's served. Forget to return the plate from a filter and the customer gets an empty table.

⚠️ The #1 filter mistake

A filter callback that doesn't return its value will wipe out the data β€” a filtered title becomes blank, filtered content disappears. Always accept the value as a parameter and return something.

Registering Callbacks

Both add_action() and add_filter() take the same four arguments:

<?php
add_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 );
add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 );
  • Priority (default 10) sets the running order when several callbacks share a hook. Lower numbers run earlier.
  • Accepted arguments (default 1) declares how many parameters your callback wants. If a hook passes three values and you need all three, set this to 3.

Callbacks can be named functions, anonymous functions (closures), or class methods:

<?php
// Anonymous function.
add_filter( 'the_content', function ( string $content ): string {
    return $content . '<p>Thanks for reading!</p>';
} );

// Instance method.
add_action( 'wp_footer', array( $this, 'render_footer_script' ) );

// Static method.
add_filter( 'the_title', array( 'My_Plugin_Filters', 'modify_title' ) );

When a hook passes extra data, request it explicitly. For example, save_post passes the post ID, the post object, and an "is this an update?" flag:

<?php
function my_plugin_on_save( int $post_id, WP_Post $post, bool $update ): void {
    if ( $update ) {
        error_log( "Post {$post_id} ({$post->post_title}) was updated." );
    }
}
// accepted_args = 3 so all three parameters are handed to our callback.
add_action( 'save_post', 'my_plugin_on_save', 10, 3 );

Common Core Hooks

WordPress fires hundreds of hooks. You'll reach for a small, well-known set constantly.

The load order (actions)

flowchart LR A[muplugins_loaded] --> B[plugins_loaded] B --> C[after_setup_theme] C --> D[init] D --> E[wp_loaded] E --> F[template_redirect]
HookTypeFires when…
initactionWordPress is loaded β€” register post types, taxonomies, shortcodes here
wp_enqueue_scriptsactionIt's time to load front-end CSS/JS
admin_menuactionAdmin menus are being built β€” add your settings page
save_postactionA post is saved β€” persist your meta data
the_contentfilterPost body is about to render β€” modify it
the_titlefilterA title is about to render β€” modify it
wp_ajax_{action}actionA logged-in AJAX request arrives (use wp_ajax_nopriv_{action} for logged-out)

πŸ“– Where to register things

Register post types, taxonomies, and shortcodes on init β€” not earlier. Enqueue scripts on wp_enqueue_scripts (front end) or admin_enqueue_scripts (admin), never by echoing <script> tags directly.

Worked Example: Reading Time

Let's build something genuinely useful β€” a "X minute read" badge added to the top of every single post. This is a classic filter job: it takes the content, prepends an estimate, and returns the result.

<?php
/**
 * Prepend an estimated reading time to single-post content.
 *
 * @param string $content The post content.
 * @return string The content, with a reading-time badge in front.
 */
function my_plugin_add_reading_time( string $content ): string {
    // Only touch the main content of single posts on the front end.
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    $word_count   = str_word_count( wp_strip_all_tags( $content ) );
    $minutes      = max( 1, (int) ceil( $word_count / 200 ) ); // ~200 wpm.

    $label = sprintf(
        /* translators: %d = number of minutes. */
        _n( '%d minute read', '%d minutes read', $minutes, 'my-plugin' ),
        $minutes
    );

    $badge = '<p class="reading-time">⏱️ ' . esc_html( $label ) . '</p>';

    return $badge . $content;
}
add_filter( 'the_content', 'my_plugin_add_reading_time' );

Rendered at the top of a post:

⏱️ 4 minutes read

Notice the guard clauses at the top: they keep the badge off archive pages, admin screens, and secondary queries. This "return early if it's not our case" pattern is the mark of a well-behaved filter.

Creating Custom Hooks

You don't just consume hooks β€” you can publish them so other developers extend your plugin the same way you extend core. Fire an action with do_action(), and offer a filter with apply_filters().

A custom action

<?php
function my_plugin_process_order( int $order_id ): void {
    // …core order processing…

    // Announce it so others can react (send email, log, sync CRM…).
    do_action( 'my_plugin_order_processed', $order_id );
}

A custom filter

<?php
function my_plugin_author_box( int $post_id ): string {
    $author_id = (int) get_post_field( 'post_author', $post_id );
    $name      = get_the_author_meta( 'display_name', $author_id );
    $bio       = get_the_author_meta( 'description', $author_id );

    $html  = '<div class="author-box">';
    $html .= '<h3>' . esc_html( $name ) . '</h3>';
    $html .= '<p>' . esc_html( $bio ) . '</p>';
    $html .= '</div>';

    // Let other plugins tweak the markup before it's used.
    return apply_filters( 'my_plugin_author_box', $html, $author_id, $post_id );
}

Now another developer can write add_action( 'my_plugin_order_processed', ... ) or add_filter( 'my_plugin_author_box', ... ) against your plugin β€” without ever editing your files. That's designing a modular system others can bolt their own parts onto.

Removing & Debugging Hooks

Sometimes the job is to undo a hook added by core or another plugin.

Removing callbacks

<?php
// Remove the "generator" meta tag core prints in the head.
remove_action( 'wp_head', 'wp_generator' );

// Stop WordPress auto-wrapping content in <p> tags.
remove_filter( 'the_content', 'wpautop' );

// To remove a class-method callback you must match the exact
// object/method AND priority it was added with.
remove_action( 'save_post', array( $some_object, 'callback' ), 10 );

⚠️ Timing matters

You can only remove a callback after it has been added. If a plugin adds its hook on init, run your remove_action() on init too (at a later priority) or on a hook that fires afterwards β€” otherwise there's nothing there yet to remove.

Inspecting hooks

<?php
// Does a callback exist? Returns the priority, or false.
if ( has_filter( 'the_content', 'wpautop' ) !== false ) {
    // wpautop is active on the_content.
}

// Only add if not already present.
if ( ! has_action( 'wp_footer', 'my_footer_function' ) ) {
    add_action( 'wp_footer', 'my_footer_function' );
}

Debugging what runs

<?php
// Dump every callback registered on a hook.
function my_plugin_debug_hook( string $hook_name ): void {
    global $wp_filter;
    if ( isset( $wp_filter[ $hook_name ] ) ) {
        echo '<pre>';
        print_r( $wp_filter[ $hook_name ] );
        echo '</pre>';
    } else {
        echo esc_html( "No callbacks found for: {$hook_name}" );
    }
}

πŸ’‘ Use Query Monitor

For day-to-day work, install the free Query Monitor plugin. It shows which hooks fired on a page, every callback attached, and their timings β€” far friendlier than print_r-ing $wp_filter by hand.

Hands-on Exercise

πŸ‹οΈ Actions and filters in practice

Objective: Use one filter and one custom action in a small plugin.

Instructions:

  1. Write a filter on the_content that appends a "Thanks for reading!" note to single posts only.
  2. Add an action on wp_footer that prints a small copyright line.
  3. Create your own action, myplugin_after_note, fired right after the "Thanks" note, and hook a second function into it to prove extensibility.
  4. Give one of your callbacks a priority of 20 and confirm the ordering behaves as expected.
πŸ’‘ Hint

In the content filter, build your extra markup into a string, call do_action( 'myplugin_after_note' ) using output buffering (ob_start() / ob_get_clean()) to capture anything hooked functions echo, then append it and return the whole thing. Remember: filters must return, actions need not.

βœ… Example solution
<?php
function myplugin_thanks_note( string $content ): string {
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    $content .= '<p class="thanks">Thanks for reading!</p>';

    // Fire our custom action and capture anything it prints.
    ob_start();
    do_action( 'myplugin_after_note' );
    $content .= ob_get_clean();

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

// Another developer (or you) can now extend it:
add_action( 'myplugin_after_note', function () {
    echo '<p class="cta">Subscribe for more!</p>';
} );

// A footer action.
add_action( 'wp_footer', function () {
    echo '<p style="text-align:center">&copy; ' . esc_html( date( 'Y' ) ) . '</p>';
}, 20 );

🎯 Quick Quiz

Question 1: You want to change a post's title text before it's displayed. Which hook type do you use?

Question 2: Two callbacks are on the same hook: one at priority 5, one at priority 15. Which runs first?

Question 3: What is the most common bug in a filter callback?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • WordPress is event-driven: core fires hooks, and your callbacks run when they do β€” hooks cost nothing when unused.
  • Actions do something (no return); filters change something and must return the value.
  • Register with add_action/add_filter, controlling order via priority and data via accepted_args.
  • Publish your own hooks with do_action/apply_filters to make a plugin extensible.
  • You can remove and inspect hooks β€” timing matters, and Query Monitor makes debugging easy.

πŸ“š Further Reading

πŸš€ What's Next?

Hooks are how everything connects β€” including admin screens. In Admin Pages and Settings API you'll use admin_menu and admin_init to build a proper settings page for your plugin.

πŸŽ‰ Hooked!

You now speak WordPress's native extension language. Let's build an admin interface with it.