Most WordPress developers are familiar with the concept of actions and filters. At the very heart of WordPress, these hooks allow developers to extend the functionality of WordPress in numerous ways. Whether you want to run a process when a post is saved, add a new section to the Edit User page, or modify the SQL used when querying the database, WordPress has hooks for (almost) everything.
One thing I’ve noticed a lot, as a frequent user of the WordPress StackExchange, is that many developers don’t know where to start when trying to figure out which actions or hooks might be available to them. In this blog post, I want to help walk through the process of tracking down various hooks with examples of when you might want to use them and how to implement them.
Actions and Filters: What’s the Difference?
This is a very basic mistake I see often when helping people figure out their WordPress issues. They want to modify the Query with the pre_get_posts filter, but they can’t figure out why their code isn’t modifying anything. Well, let’s take a look at a basic example that sets the post_type parameter to “page”:
add_action( 'pre_get_posts', function( $query ) { $query->set( 'post_type', 'page' ); return $query; } );
Pretty straightforward, right?
On the surface, this looks like a proper filter but it will never work. The reason is that WordPress makes a distinction between actions and filters. Actions are assigned via add_action, while filters are assigned via add_filter. The corresponding methods to call these are do_action and apply_filters, respectively. Under the hood, there’s actually not much difference. In fact, add_action calls add_filter. Here’s the full source code from WordPress:
function add_action($tag, $function_to_add, $priority = 10, $accepted_args = 1) { return add_filter($tag, $function_to_add, $priority, $accepted_args); }
Kind of crazy, right?
It’s because add_filter and add_action do roughly the same thing at the core level, with one minor exception: the returned value of apply_filters can be used to modify existing data structures, while do_action returns literally nothing (void in PHP).
So, our above example will never return any value to modify the query, as do_action simply doesn’t do that. While these differences may make you want to ask, “Why even have different methods?” the distinction is very important.
Actions are for when you want something to happen as a result of something else happening, while filters are used to modify data at run time. Our above example will work with the exact same code, with one minor modification:
add_filter( 'pre_get_posts', function( $query ) { $query->set( 'post_type', 'page' ); return $query; } );
In WordPress-speak, we use the term “hook” to refer to actions and filters interchangeably, as they are roughly the same thing. When talking about a specific hook, we use either action or term. (Example: “You want to use the admin_enqueue_scripts action to add scripts to WP Admin,” or, “You can use the body_class filter to add additional CSS classes to a page’s <body> tag.”)
Finding the Right Hook
WordPress has a lot of hooks to use. I mean, a lot. A rough count of the WordPress codebase puts the number of hooks called at around 2,744. That’s a lot of hooks!
So, how do you find the right one? Well, you can refer to the action and filter references linked above (and also check out the Plugin API landing page), but those references cover everything and, as we just discussed. That’s a lot of things.
Furthermore, some of the hooks are still undocumented to this day in the Codex. In some cases, the best way is to identify the method that you want to hook into and then check it out in the source code. Additionally, you might learn some interesting things about the hooks once you dive into where they are called from.
Example 1: The save_post Action
The save_post action is triggered by wp_insert_post and wp_publish_post. Both methods are defined in wp-includes/post.php. If we dig into the source code, we’ll first find this definition of save_post:
/** * Fires once a post has been saved. * * @since 1.5.0 * * @param int $post_ID Post ID. * @param WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated or not. */ do_action( 'save_post', $post_ID, $post, $update );
What’s interesting here is that directly above it we can also see this:
/** * Fires once a post has been saved. * * The dynamic portion of the hook name, `$post->post_type`, refers to * the post type slug. * * @since 3.7.0 * * @param int $post_ID Post ID. * @param WP_Post $post Post object. * @param bool $update Whether this is an existing post being updated or not. */ do_action( "save_post_{$post->post_type}", $post_ID, $post, $update );
That’s neat! We can actually target save_post for our own specific post type. This saves a few lines of doing a check like this:
if ( 'my_custom_post_type' !== $post->post_type ) { return; }
Another important note when it comes to hooks is not to hook something that might bite you later. For example, save_post is a great action to use when you want to do something after a post saves. For instance, you might want to record a new post entry for your publish_log custom post type that tracks when posts are published. Let’s take a look at an example:
function maybe_create_publish_log( $post_id, $post ) { if ( 'publish' !== $post->post_status ) { return; } wp_insert_post( [ 'post_type' => 'publish_log', 'post_author' => $post->post_author, 'post_status' => 'publish', 'post_content' => "New post {$post->post_title} published on {$post->post_date}.", // etc... ] ); } add_action( 'save_post', 'maybe_create_publish_log', 10, 2 );
At first glance, this seems fine. But remember earlier when we learned that save_post is called by wp_insert_post? With this code, you might find yourself creating tons of publish_log posts because your hook to save_post is being called over and over by wp_insert_post. So, how do you get around this?
The answer is the aptly named remove_action (if you guessed that its sister function is remove_filter and that remove_action simply calls remove_filter, you get a cookie). Let’s take a look at our code now:
function maybe_create_publish_log( $post_id, $post ) { if ( 'publish' !== $post->post_status ) { return; } remove_action( 'save_post', 'maybe_create_publish_log' ); wp_insert_post( [ 'post_type' => 'publish_log', 'post_author' => $post->post_author, 'post_status' => 'publish', 'post_content' => "New post {$post->post_title} published on {$post->post_date}.", // etc... ] ); add_action( 'save_post', 'maybe_create_publish_log', 10, 2 ); } add_action( 'save_post', 'maybe_create_publish_log', 10, 2 );
This is one of the benefits to diving into the core files: if you look up where your action is called from, you will know if you’re about to get into a publishing loop, or avoid other possible “gotchas” when developing your plugins and themes.
Example 2: Modifying the Edit User Screen
Recently at WebDevStudios, we’ve had a couple of clients that needed specific sections added to the user profile page. You may be looking to integrate a service, such as Medium, to your user accounts, or you may want to give administrators the ability to modify fields specific to a plugin. But where do you start?
If you look in the action reference, well, you might be looking for a long time. You’d eventually find what you’re looking for, but there is an easier way.
First, on a WordPress install, you’ll notice you’re at this page when editing a User: /wp-admin/user-edit.php?user_id=3. If you’re editing your own user, you’ll find that you’re at /wp-admin/profile.php. You might be thinking, “Oh geez, I have to dig into two files to find the action I want?!”
But fear not, because upon opening profile.php you’ll see it’s actually rather simple:
<?php /** * User Profile Administration Screen. * * @package WordPress * @subpackage Administration */ /** * This is a profile page. * * @since 2.5.0 * @var bool */ define('IS_PROFILE_PAGE', true); /** Load User Editing Page */ require_once( dirname( __FILE__ ) . '/user-edit.php' );
Well, that saves us some time. Now, if you dig into user-edit.php, you’re going to be looking for calls to do_action. Let’s do a quick check with ag – the silver searcher:
ag do_action wp-admin/user-edit.php
Which gives us the following output:
132: do_action( 'personal_options_update', $user_id ); 141: do_action( 'edit_user_profile_update', $user_id ); 230: do_action( 'user_edit_form_tag' ); 285: do_action( 'admin_color_scheme_picker', $user_id ); 347:do_action( 'personal_options', $profileuser ); 362: do_action( 'profile_personal_options', $profileuser ); 658: do_action( 'show_user_profile', $profileuser ); 667: do_action( 'edit_user_profile', $profileuser );
Now, let’s dig into the actual file itself. Odds are we want to be looking at edit_user_profile specifically:
if ( IS_PROFILE_PAGE ) { /** * Fires after the 'About Yourself' settings table on the 'Your Profile' editing screen. * * The action only fires if the current user is editing their own profile. * * @since 2.0.0 * * @param WP_User $profileuser The current WP_User object. */ do_action( 'show_user_profile', $profileuser ); } else { /** * Fires after the 'About the User' settings table on the 'Edit User' screen. * * @since 2.0.0 * * @param WP_User $profileuser The current WP_User object. */ do_action( 'edit_user_profile', $profileuser ); }
You’ll notice that I copied out the surrounding if conditional. As well, you might notice something familiar on the first line: the if ( IS_PROFILE_PAGE ) check lets you hook in just on your own user page, or only on other users’ pages, or both by a combination of both actions.
With these actions, we can render a custom set of fields to modify the user edit page and bring custom functionality right into the core of WordPress. While the Codex’s action list is quite extensive and sometimes difficult to navigate, it is a wonderful resource for finding additional information on most actions and filters once you know what to look for. Have a peek at the page for edit_user_profile, for example.
Example 3: Modifying Query SQL with Filters
This is a slightly more advanced example, but an important one. WordPress does a lot of things under the hood to construct the MySQL queries that ultimately find your posts, tags, etc. Let’s start with an example WP Query from get_posts:
$args = [ 'post_type' => 'post', 'posts_per_page' => 5, 'orderby' => 'post_date', 'order' => 'ASC', 's' => 'Test', ]; $posts = get_posts( $args );
The resulting SQL looks something like this:
SELECT wp_posts.ID FROM wp_posts WHERE 1=1 AND (((wp_posts.post_title LIKE '%Test%') OR (wp_posts.post_excerpt LIKE '%Test%') OR (wp_posts.post_content LIKE '%Test%'))) AND (wp_posts.post_password = '') AND wp_posts.post_type = 'post' AND ((wp_posts.post_status = 'publish')) ORDER BY wp_posts.post_date ASC LIMIT 0, 5
This query, although simple, is filtered several times before it is passed to the database for record retrieval. A quick aside—the “%” signs may be encoded as long hashes when you view the query, as a placeholder to help with how $wpdb parses placeholders.
Next, we’ll step through some of the more useful filters used. The following code can be found in wp-includes/class-wp-query.php.
posts_where
This is one of the earliest filters on the query, and filters the WHERE clause of the query. For the above example, this filter’s parameters are the WHERE clause as the first parameter, and a reference to the query object as the second. The WHERE clause looks like this:
[0] => AND (((wp_posts.post_title LIKE '{67feda5925d0533a58f19e45c96ff1c761ddaa2263949b4ae0023a90b3f25b1f}Test{67feda5925d0533a58f19e45c96ff1c761ddaa2263949b4ae0023a90b3f25b1f}') OR (wp_posts.post_excerpt LIKE '{67feda5925d0533a58f19e45c96ff1c761ddaa2263949b4ae0023a90b3f25b1f}Test{67feda5925d0533a58f19e45c96ff1c761ddaa2263949b4ae0023a90b3f25b1f}') OR (wp_posts.post_content LIKE '{67feda5925d0533a58f19e45c96ff1c761ddaa2263949b4ae0023a90b3f25b1f}Test{67feda5925d0533a58f19e45c96ff1c761ddaa2263949b4ae0023a90b3f25b1f}'))) AND (wp_posts.post_password = '')
Two notes about the above: first, you can see the expanded version of the placeholder for the “%” symbol. Second, the query clause starts with an AND. You may have noticed in the original query that this clause starts with WHERE 1=1. This is a simple trick to allow adding more conditions to the WHERE clause. If we wanted to add an extra field to check against when someone searches, say from another table, we could do something like this:
add_filter( 'posts_where', function( $where, $query ) { if ( 'post' !== $query->get( 'post_type' ) { return $where; } if ( ! $query->get( 's' ) ) { return $where; } $search = $query->get( 's' ); $where .= " AND mycustomtable.ad_keywords LIKE '%{$search}%'"; return $where; }, 10, 2 );
This would allow us to search across our custom table for rows with a matching search in the ad_keywords column. If you’re familiar with SQL, you’re probably wondering how we’re querying the mycustomtable table without a JOIN. Well, right below the call to filter posts_where is our next guest…
posts_join
The posts_join filter allows us to write JOIN clauses to other tables we might need in our query. Currently, our query doesn’t join to any other tables since we aren’t doing a taxonomy query or meta query. However, if we want to JOIN our custom table, it’s pretty straight forward.
add_filter( 'posts_join', function( $joins, $query ) { if ( 'post' !== $query->get( 'post_type' ) { return $joins; } if ( ! $query->get( 's' ) ) { return $joins; } $joins .= 'JOIN mycustomtable ON( wp_posts.ID = mycustomtable.post_id )'; return $joins; }, 10, 2 );
Other Filters
While the above two filters make up the bulk of useful filters for most cases, the following may also be relevant depending on your use-case:
- posts_where_paged and posts_join_paged – Similar to the above two filters, but used expressly when pagination is concerned
- posts_orerby – Direct access to the ORDER BY clause, responsible for the order in which your posts appear when queried
- post_limits – The query’s LIMIT clause
- posts_fields – Determines which fields are returned from the database (note that WordPress does a lot of this for you when it gets the actual post objects, typically you wouldn’t need to modify this outside of very specific use-cases)
- posts_clauses – This is kind of a catchall for various parts of the query, including WHERE, GROUP BY, JOIN, ORDER BY, DISTINCT, selected fields, and LIMIT.
Conclusion
With the above, I hope you have a better understanding of how and where WordPress filters certain things, and I hope you go off and explore the codebase the next time you say, “Geez, I really wish I could modify that!” WordPress has plenty of actions for developers to tap into once you know where to look. Happy coding!
Also published on Medium.