
WordPress Hooks Explained: How to Use Actions and Filters?
Every WordPress tutorial eventually says "add this to your plugin" and shows a line like add_action( 'init', 'my_function' ) or add_filter( 'the_content', 'my_function' ). Copying those lines works. Understanding them is what lets you write your own customizations, debug why a snippet does nothing, and figure out how to undo something another plugin did. Hooks are the single most important concept in WordPress development: core, every plugin, and every theme use them to talk to each other without editing each other's files.
This article explains what hooks are and how WordPress runs them, the difference between actions and filters, how priority and accepted arguments work, how to remove callbacks added by themes and plugins, how to create your own hooks, the order of the most important hooks during a request, and the tools for seeing which hooks run on a page.
What Are WordPress Hooks?
A hook is a named point in the code where WordPress lets other code run. Core calls a hook by name; anything that has registered a callback for that name gets executed at that moment. There are two kinds:
- Actions say "something is happening now, do whatever you need." Callbacks run, and any return value is ignored. Examples:
init,wp_enqueue_scripts,save_post. - Filters say "here is a value, return it, changed or not." Each callback receives the value and must return it, and the final result is used by the code that called the filter. Examples:
the_content,excerpt_length,body_class.
| Aspect | Action | Filter |
|---|---|---|
| Purpose | Do something at a specific moment | Modify a value before it is used |
| Fired with | do_action() | apply_filters() |
| Registered with | add_action() | add_filter() |
| Callback must return | Nothing | The (possibly modified) first argument |
| Typical uses | Enqueue assets, register post types, send email | Change text, add classes, alter queries |
Under the hood, both live in the same registry, the global $wp_filter array of WP_Hook objects. add_action() is literally a wrapper around add_filter(). The difference is in how you use them: actions are for side effects, filters are for transforming values.
How Actions Work
Core fires an action with do_action():
<?php
// Somewhere in WordPress core
do_action( 'wp_footer' );
Your code registers a callback with add_action():
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_footer_notice() {
echo '<p class="wsm-footer-notice">' . esc_html__( 'Thanks for reading!', 'wsm' ) . '</p>';
}
add_action( 'wp_footer', 'wsm_footer_notice' );
When the theme calls wp_footer(), WordPress runs do_action( 'wp_footer' ), finds your callback in the registry, and calls it.
A more typical example is enqueueing assets. Scripts and styles must be registered on wp_enqueue_scripts for the front end and admin_enqueue_scripts for the admin:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_enqueue_assets() {
wp_enqueue_style(
'wsm-styles',
plugins_url( 'assets/style.css', __FILE__ ),
array(),
'1.0.0'
);
}
add_action( 'wp_enqueue_scripts', 'wsm_enqueue_assets' );
Some actions pass data to your callback. save_post passes the post ID, the post object, and whether it is an update:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_log_published_post( $post_id, $post, $update ) {
if ( wp_is_post_revision( $post_id ) || 'publish' !== $post->post_status ) {
return;
}
error_log( sprintf( 'Post %d saved (update: %s)', $post_id, $update ? 'yes' : 'no' ) );
}
add_action( 'save_post', 'wsm_log_published_post', 10, 3 );
The last two arguments, 10 and 3, are the priority and the number of accepted arguments. Both are explained below.
How Filters Work
Core passes a value through a filter with apply_filters() and uses whatever comes back:
<?php
// Simplified from WordPress core
$length = apply_filters( 'excerpt_length', 55 );
Your callback receives the current value and returns a new one:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_excerpt_length( $length ) {
return 30;
}
add_filter( 'excerpt_length', 'wsm_excerpt_length' );
Filters often add to a value rather than replace it. body_class passes an array of classes for the body element:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_body_class( $classes ) {
if ( is_singular( 'post' ) && has_post_thumbnail() ) {
$classes[] = 'has-featured-image';
}
return $classes;
}
add_filter( 'body_class', 'wsm_body_class' );
And the_content lets you change post content before it is displayed:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_append_cta( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$cta = '<p class="wsm-cta">' . esc_html__( 'Enjoyed this? Subscribe to our newsletter.', 'wsm' ) . '</p>';
return $content . $cta;
}
add_filter( 'the_content', 'wsm_append_cta' );
The golden rule of filters: always return a value, even when you do not change it. A filter callback that returns nothing replaces the value with null. On the_content, that blanks every post on the site.
Equally important: filters should not have side effects. Do not echo output, send email, or write to the database inside a filter. Filters can run many times per request, in contexts you did not expect, such as feeds, REST responses, and excerpts.
Priority: Controlling the Order of Callbacks
The third argument to add_action() and add_filter() is the priority, an integer that defaults to 10. Lower numbers run earlier. Callbacks with the same priority run in the order they were added.
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
add_filter( 'the_content', 'wsm_runs_first', 5 );
add_filter( 'the_content', 'wsm_runs_second' ); // Priority 10.
add_filter( 'the_content', 'wsm_runs_last', 99 );
Priority matters whenever more than one piece of code touches the same hook:
- To run after another plugin, use a higher number than it does. To override a value someone else filters at 10, filter it at 20.
- To run before core processing, use a lower number. For example,
the_contentrunsdo_blocksat priority 9 andwpautopat 10, so a callback at priority 8 sees raw block markup. PHP_INT_MAXguarantees you run last, but use it sparingly. If every plugin does that, priority stops meaning anything.
Accepted Arguments
The fourth argument is how many arguments your callback receives. It defaults to 1. If a hook passes more and you need them, you must ask for them:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_title_prefix( $title, $post_id ) {
if ( 'product' === get_post_type( $post_id ) && ! is_admin() ) {
return 'New: ' . $title;
}
return $title;
}
add_filter( 'the_title', 'wsm_title_prefix', 10, 2 );
the_title passes the title and the post ID. Without the 2, $post_id would not be passed and PHP would throw an ArgumentCountError. To find out what a hook passes, look it up in the WordPress code reference or read the do_action() or apply_filters() call in the source.
Hooking Class Methods and Closures
Callbacks do not have to be named functions. Any PHP callable works:
<?php
// wp-content/plugins/wsm-hooks-demo/includes/class-wsm-plugin.php
class WSM_Plugin {
public function __construct() {
add_action( 'init', array( $this, 'register_post_types' ) );
add_filter( 'excerpt_more', array( $this, 'excerpt_more' ) );
add_action( 'wp_footer', array( __CLASS__, 'static_footer' ) );
}
public function register_post_types() {
register_post_type(
'wsm_project',
array(
'label' => __( 'Projects', 'wsm' ),
'public' => true,
'show_in_rest' => true,
)
);
}
public function excerpt_more( $more ) {
return '…';
}
public static function static_footer() {
// Static methods use the class name instead of $this.
}
}
new WSM_Plugin();
Anonymous functions are convenient for small snippets:
<?php
add_filter( 'login_errors', function () {
return __( 'Invalid login details.', 'wsm' );
} );
The trade-off is that closures cannot be removed by other code, because there is no name to reference. Use named functions or methods for anything another developer might reasonably want to unhook.
Removing Actions and Filters
Sometimes you need to undo what a theme or plugin does. remove_action() and remove_filter() take the same hook name, callback, and priority used when it was added:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_cleanup_head() {
remove_action( 'wp_head', 'wp_generator' );
remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' );
}
add_action( 'init', 'wsm_cleanup_head' );
Three rules make removal work:
- The priority must match exactly. The emoji script is added at priority 7, so it must be removed at 7. If the priority does not match, removal silently fails.
- Removal must happen after the callback was added and before the hook fires. If a theme adds a callback in
functions.php, removing it from a plugin's main file is too early, because plugins load before themes. Run the removal on a later hook, such asafter_setup_themeorinit. - For class methods, you need the same object. If a plugin stores its instance somewhere accessible, such as a global or a static
instance()method, pass that object. Otherwise, the callback cannot be targeted cleanly.
<?php
// Removing a method added by another plugin's singleton.
add_action( 'init', function () {
if ( class_exists( 'Some_Plugin' ) ) {
remove_action( 'wp_footer', array( Some_Plugin::instance(), 'render_badge' ), 10 );
}
} );
To remove every callback on a hook, remove_all_actions( 'hook_name' ) and remove_all_filters( 'hook_name' ) exist, but they are blunt tools that can break core features. Prefer targeted removal.
Creating Your Own Hooks
Plugins and themes should offer hooks too. Custom hooks let other developers extend your code without editing it, the same way you extend WordPress:
<?php
// wp-content/plugins/wsm-reading-time/includes/functions.php
function wsm_rt_get_label( $minutes ) {
$label = sprintf(
/* translators: %d: number of minutes. */
_n( '%d minute read', '%d minutes read', $minutes, 'wsm' ),
$minutes
);
/**
* Filters the reading time label.
*
* @param string $label The label text.
* @param int $minutes Estimated reading time in minutes.
*/
return apply_filters( 'wsm_rt_label', $label, $minutes );
}
function wsm_rt_render( $minutes ) {
do_action( 'wsm_rt_before_label', $minutes );
echo '<p class="wsm-reading-time">' . esc_html( wsm_rt_get_label( $minutes ) ) . '</p>';
do_action( 'wsm_rt_after_label', $minutes );
}
Another developer can now change your label without touching your files:
<?php
add_filter( 'wsm_rt_label', function ( $label, $minutes ) {
return $minutes > 10 ? $label . ' (long read)' : $label;
}, 10, 2 );
Good custom hooks share a few traits: they are prefixed with your plugin's slug, documented with a DocBlock listing parameters, and stable once released, since renaming a hook breaks everyone who uses it. If you must change one, use apply_filters_deprecated() or do_action_deprecated() for a transition period. Building a plugin with hooks like these is covered step by step in how to write your first WordPress plugin.
The Order of Important Hooks
Knowing when hooks fire tells you where to put your code. This is the simplified order on a normal front-end request:
| Hook | What has happened by then | Use it for |
|---|---|---|
muplugins_loaded | Must-use plugins are loaded | Very early setup in MU plugins |
plugins_loaded | All active plugins are loaded | Checking for other plugins, early integrations |
after_setup_theme | The theme's functions.php is loaded | Theme supports, image sizes, removing theme hooks |
init | WordPress is loaded, user is authenticated | Post types, taxonomies, shortcodes, blocks |
wp_loaded | Everything is fully loaded | Late setup that depends on all init work |
parse_request | The URL has been matched to query variables | Custom routing |
pre_get_posts | The main query is about to run | Changing the main query |
wp | The main query has run | Logic that needs the queried object |
template_redirect | Right before the template is chosen | Redirects, access control |
wp_enqueue_scripts | Inside wp_head() | Enqueueing CSS and JavaScript |
wp_head | Inside the head element | Meta tags, inline data |
wp_footer | Before the closing body tag | Footer scripts and markup |
shutdown | After the response is sent | Cleanup and logging |
Admin requests fire admin_menu, admin_init, and admin_enqueue_scripts instead of the template hooks. REST API requests fire rest_api_init, which is where custom endpoints are registered.
pre_get_posts: The Most Useful Action
pre_get_posts deserves a special mention because it replaces most custom queries in templates. It passes the WP_Query object by reference, so you change it directly instead of returning it:
<?php
// wp-content/plugins/wsm-hooks-demo/wsm-hooks-demo.php
function wsm_tweak_main_query( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_home() ) {
$query->set( 'posts_per_page', 12 );
}
if ( $query->is_search() ) {
$query->set( 'post_type', array( 'post', 'page' ) );
}
}
add_action( 'pre_get_posts', 'wsm_tweak_main_query' );
The is_admin() and is_main_query() checks are essential. Without them, the change also applies to admin screens, widgets, and every secondary query on the page.
Finding and Debugging Hooks
- Query Monitor. This free plugin's Hooks & Actions panel lists every hook fired on the current page and the callbacks attached to each, with priorities and the component that added them. It is the fastest way to see why something runs or does not.
- Helper functions.
has_action( 'hook', 'callback' )andhas_filter()return the priority of a registered callback orfalse.did_action( 'init' )returns how many times an action has fired, which is useful for guarding code that must run after a hook.current_filter()returns the name of the hook currently running. - The code reference. Every core hook has a page on developer.wordpress.org listing its parameters, the file it lives in, and its version history.
- Searching the source. Searching a plugin's code for
do_action(andapply_filters(reveals every extension point it offers.
Common Problems and Fixes
- My callback never runs. The hook already fired before
add_action()was called, the hook name is misspelled, or the code is in a file that is not loaded. Check withdid_action()and Query Monitor. - My filter makes content disappear. The callback does not return a value on every code path. Make sure every branch ends with
return. ArgumentCountError: Too few arguments. The callback declares more parameters than the accepted arguments value allows. Pass the fourth argument toadd_filter()oradd_action().remove_action()does nothing. The priority does not match, it ran before the callback was added, or the callback is a closure or an object method you cannot reference. Move the removal to a later hook and match the priority.- My change affects the admin or other queries. The callback has no context checks. Add
is_admin(),is_main_query(),in_the_loop(), or post type checks. - Output appears at the top of the page. An action that was meant to return a value is echoing it, or a filter is echoing. Filters must return, never echo.
WordPress Hooks FAQ
An action runs your code at a specific moment and ignores any return value, for example when a post is saved. A filter passes a value to your code and uses whatever you return, for example the post content before it is displayed.
It controls the order in which callbacks on the same hook run. The default is 10, lower numbers run earlier, and callbacks with the same priority run in the order they were added.
The hook name, callback, and priority must match exactly what was used in add_action, and the removal must run after the callback was added but before the hook fires. Closures and methods on objects you cannot reach cannot be removed this way.
Yes. A callback can be attached to as many hooks as you like. Use current_filter inside it if you need to know which hook triggered it.
No. The Block Hooks API automatically inserts blocks next to other blocks in templates, such as adding a block after the post content. It is a separate feature with a confusingly similar name, and it does not use add_action or add_filter.
In a plugin if it adds functionality, or in a child theme's functions.php if it only changes the presentation of that theme. Avoid editing a parent theme or core files, because updates will overwrite your changes.
Conclusion
Hooks are how WordPress stays extensible. Actions let your code run at the right moment, filters let it change values before they are used, and both rely on the same registry of callbacks ordered by priority. Once you know that, most WordPress customization becomes a matter of finding the right hook and attaching a small, focused function to it.
Remember the habits that prevent most hook bugs: return a value from every filter, avoid side effects in filters, add context checks so your changes only apply where you intend, match the priority when removing callbacks, and run removals on a hook that fires after the original was added. Then return the favor and add well-named, documented hooks to your own plugins so others can extend them. For more practical examples of hooks in action, see how to troubleshoot common WordPress errors, where a misbehaving callback is often the root cause.
Here are some useful references for going deeper on WordPress hooks:
- WordPress Developer Resources: Hooks — the Plugin Handbook's introduction to actions, filters, and custom hooks.
- WordPress Developer Resources: add_filter() — function reference including priority and accepted arguments.
- WordPress Developer Resources: Hooks reference — searchable list of every core action and filter.
- WordPress Developer Resources: Action reference — the typical order of actions during a request.
- WordPress.org Plugins: Query Monitor — inspect hooks, callbacks, and priorities on any page.


