WordPress Hooks: Actions and Filters Explained with Real Examples
How WordPress actions and filters actually work, when to use each, priority and argument gotchas, and how to hook without breaking updates.

In this article
Every WordPress site you have ever touched runs on hooks. Themes, plugins and WordPress core itself are mostly a long list of callbacks attached to named moments in a request, and the whole extension model rests on that one idea.
Most hook tutorials stop at add_action and a hello-world example. The part that actually costs developers hours is the other half: working out why a hook did nothing. So this guide covers the mechanics quickly and then spends its time on load order, priority, arguments and the four reasons a hook silently fails.
The one-sentence difference: actions do, filters change
An action is a point in execution where WordPress says 'this is happening now — anyone want to join in?'. Your callback runs, does its work, and whatever it returns is ignored. Sending an email when a post is published, registering a post type during init, printing a tag in the head: all actions.
A filter is a value passing through a checkpoint. WordPress hands your callback the value, your callback hands back the value it should be, and WordPress carries on with whatever you returned. Changing the excerpt length, adding a class to the body tag or altering a query are filters.
Under the hood the two are almost the same machine — add_action is literally a wrapper around add_filter — but the contract is different. A filter callback must return something. Forget the return statement and the value becomes null, which is how a single missing line can blank every post title on a site.
Reading the signature: how many arguments a hook really passes
The full registration is add_action( $hook, $callback, $priority, $accepted_args ), and the last parameter is the one people forget. It defaults to 1, which means your callback receives only the first argument the hook passes, even if the hook passes four.
Take save_post. It fires with three arguments: the post ID, the post object and a boolean saying whether this was an update. Register it with the defaults and your function only ever sees the ID. If you then write a callback that expects $post as its second parameter, PHP will throw an ArgumentCountError and you will spend a while wondering why.
The fix is to declare the count explicitly: add_action( 'save_post', 'my_callback', 10, 3 ). The quickest way to find out what a hook passes is to search core for the do_action or apply_filters call that fires it. The arguments after the hook name are exactly what your callback can receive, in that order.
Priority, and why 10 is not a safe default in a busy theme
When several callbacks attach to the same hook, priority decides the order. Lower numbers run earlier, and callbacks with the same priority run in the order they were added. The default is 10, which means on a site with a page builder, an SEO plugin and a theme framework, priority 10 on a popular hook is a crowded room.
That matters most for filters. If your callback modifies the content at priority 10 and another plugin also runs at 10 but was added later, its change wins. Running at 20 makes yours the later word; running at 5 lets others build on what you did. Choose the number deliberately and write a short comment saying why — the next developer will thank you.
Avoid escalating to PHP_INT_MAX as a reflex. It works until two plugins both do it, and then you are back to load order with a less readable number.
Where to register hooks so they run at all
A hook can only call your function if your add_action ran before the hook fired. That sounds obvious, but it is behind more broken code than any other mistake. WordPress moves through its startup in a fixed sequence, and you need to know roughly where each step sits.
- plugins_loaded fires once every active plugin file has been included. It is the earliest safe point to check whether another plugin exists or to load your text domain.
- after_setup_theme fires after the theme's functions.php loads. Theme supports, image sizes and navigation menus are registered here.
- init is where post types, taxonomies, shortcodes and rewrite rules belong. Most general setup work goes here.
- wp_loaded fires once WordPress, plugins and the theme are fully loaded but before any output — useful for handling form submissions.
- template_redirect runs after the main query, so conditional tags such as is_singular finally return correct answers. It is the right place to redirect.
- wp_enqueue_scripts and admin_enqueue_scripts are the only correct places to enqueue assets, and wp_head and wp_footer are where output is printed.
Removing and replacing hooks a plugin registered
Sooner or later you will need to stop a plugin or parent theme from doing something it hooked in. remove_action and remove_filter do that, but they only succeed when three things match exactly: the hook name, the callback and the priority it was added with. Get the priority wrong and the call returns false without complaint.
Timing matters here too. You cannot remove a callback that has not been added yet, so if a plugin adds its hook on init, your removal has to run on init at a later priority, or on a later hook entirely. Removing it from the top of functions.php usually runs too early.
Callbacks registered as object methods are the awkward case. remove_action needs the same object instance, which the plugin may keep in a global, a static property or a singleton accessor. Look for how the plugin exposes its main class. If it truly is unreachable, filtering the output or the value downstream is often cleaner than fighting the registration.
Finally, anonymous functions cannot be removed at all, because there is no reference to pass back. If you write a plugin other people will extend, use named functions or methods for anything someone might reasonably want to switch off.
Four reasons a hook silently does nothing
When a hook appears to do nothing, there is almost always one of four causes. Work through them in order, because each one has a quick test.
- It already fired. Check with did_action( 'hook_name' ) at the point you register. If it returns a number greater than zero, you are too late and need an earlier registration point.
- It never fires on this request. Some hooks only run in the admin, on REST requests, during cron or on a specific template. Put a temporary error_log call inside the callback, or use the Hooks panel in Query Monitor, to see whether it runs at all.
- It ran, but something overwrote you. Another callback at a later priority changed the value back, or did the same job differently. Query Monitor lists every callback attached to a hook with its priority, which usually makes the culprit obvious.
- It ran with arguments you did not expect. You accepted one argument when you needed three, or the hook passed an object where you expected an ID. Log what you receive with var_export before assuming the logic is wrong.
Writing your own hooks so others can extend your code
Once you are comfortable consuming hooks, add them to your own themes and plugins. A do_action before and after a significant operation, and an apply_filters around any value someone might reasonably want to adjust, turn a closed piece of code into one the next developer can extend without editing your files.
Prefix every hook name with your plugin's slug so it cannot collide with anything else, pass the context a callback would need — usually the object being worked on — and document the arguments in a comment directly above the call. That comment is the documentation people will actually read.
Frequently asked questions
What is the difference between an action and a filter in WordPress?
An action runs your code at a specific moment and ignores anything you return; it is for doing something, like sending an email or registering a post type. A filter passes a value through your callback and uses whatever you return; it is for changing something, like a title or a query. Filter callbacks must always return a value.
What does the priority number in add_action do?
It decides the order in which callbacks on the same hook run. Lower numbers run first and the default is 10. Callbacks with equal priority run in the order they were added. For filters, a later priority gets the final say on the value.
Why is my WordPress hook not firing?
Usually because you registered it after the hook had already run, or because the hook never runs on that kind of request. Check did_action for the hook at the point you register, and add a temporary error_log inside the callback or use Query Monitor's Hooks panel to confirm whether it runs.
Can I remove a hook added by a plugin or theme?
Yes, with remove_action or remove_filter, provided you pass the same hook name, the same callback and the same priority, and you call it after the plugin has added the hook. Class-method callbacks need the same object instance, and anonymous functions cannot be removed.
How many arguments does a WordPress hook pass to my function?
As many as the do_action or apply_filters call provides, but your callback only receives the number you declare in the fourth parameter of add_action or add_filter, which defaults to one. Search WordPress core for the hook name to see the full list of arguments it passes.