πͺ 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.
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.
| Action hooks | Filter hooks | |
|---|---|---|
| Purpose | Perform an operation at a point in time | Modify a piece of data before it's used |
| Mindset | An event you respond to | A value that passes through you |
| Return value | None | Must return the (modified) value |
| Register with | add_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 to3.
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)
| Hook | Type | Fires when⦠|
|---|---|---|
init | action | WordPress is loaded β register post types, taxonomies, shortcodes here |
wp_enqueue_scripts | action | It's time to load front-end CSS/JS |
admin_menu | action | Admin menus are being built β add your settings page |
save_post | action | A post is saved β persist your meta data |
the_content | filter | Post body is about to render β modify it |
the_title | filter | A title is about to render β modify it |
wp_ajax_{action} | action | A 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:
- Write a filter on
the_contentthat appends a "Thanks for reading!" note to single posts only. - Add an action on
wp_footerthat prints a small copyright line. - Create your own action,
myplugin_after_note, fired right after the "Thanks" note, and hook a second function into it to prove extensibility. - Give one of your callbacks a priority of
20and 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">© ' . 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_filtersto make a plugin extensible. - You can remove and inspect hooks β timing matters, and Query Monitor makes debugging easy.
π Further Reading
- WordPress Hooks (Plugin Handbook)
- add_filter() reference
- Query Monitor β developer debugging plugin
π 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.