Learn how to use WordPress hooks and filters to modify WordPress behavior without touching core files. This guide covers basics, advanced examples, and best practices for implementing actions and filters.
Sooner or later WordPress won’t do the one thing you need, and you’ll be tempted to edit a core file to force it. Don’t. The next update wipes your change and you’re back to square one. Hooks and filters exist so you never have to. They let you plug your own code into WordPress at the right moment, from your theme or a plugin, and your work survives every core update.
This is part of our Mastering Advanced WordPress Development series. Here we’ll walk through the two kinds of hooks, actions and filters, how to add and remove them, and the habits that keep them from turning into a mess later. Examples run from a plain “hello world” up to modifying queries, so you can lift what you need.
WordPress Actions
Actions let you run your own code at a specific point in WordPress’s execution. An action fires, and any function you’ve attached to it runs. It doesn’t hand you data to change and it doesn’t expect anything back. Think of it as WordPress saying “I’m at this point now, anyone want to do something?”
WordPress ships with hundreds of these, like wp_head and init, and you can register your own too.
Hooking into Actions
You attach a function to an action with add_action(). At minimum it takes two arguments: the hook name and the function to run when that hook fires.
<?php
/**
* Adds a custom action to the wp_head hook.
*
* @return void
*/
function my_custom_function() {
// Your code here
}
add_action( 'wp_head', 'my_custom_function' );Now my_custom_function() runs every time the wp_head action fires, which is when WordPress builds the <head> of your page.
When more than one function is attached to the same hook, priority decides the order. It defaults to 10, and you set it with a third argument. Lower numbers run earlier.
<?php
/**
* Adds a custom action to the wp_head hook with a priority of 5.
*
* @return void
*/
function my_custom_function() {
// Your code here
}
add_action( 'wp_head', 'my_custom_function', 5 );Priority 5 runs before anything left at the default 10. There’s also a fourth argument, $accepted_args, which defaults to 1 and tells WordPress how many arguments to pass your callback. You only touch it when the hook passes more than one value.
Removing Actions
You can pull off actions that a plugin or theme added, using remove_action(). Handy when something else is hooking in and you’d rather it didn’t.
<?php
/**
* Removes a custom action from the wp_print_styles hook.
*
* @return void
*/
function remove_styles() {
// Your code here
}
remove_action( 'wp_print_styles', 'my_custom_function' );One thing to get right: remove_action() matches on the exact hook name and callback that were registered. The names have to line up with the original add_action() call, or nothing gets removed and you’re left wondering why. Timing matters too, since you can only remove a hook after it’s been added.
Basic Example of Adding an Action
Here’s the smallest version that actually does something, printing text into the head:
<?php
/**
* Adds a custom action to the wp_head hook that echoes "Hello World!".
*
* @return void
*/
function my_custom_function() {
echo "Hello World!";
}
add_action( 'wp_head', 'my_custom_function' );When wp_head fires, “Hello World!” prints into the head of your site. Not useful on its own, but it proves the wiring works.
Intermediate Example: Enqueueing Scripts
The proper way to load a script is through the wp_enqueue_scripts action, not a hardcoded tag:
<?php
/**
* Enqueues a custom script on the front end of a WordPress site.
*
* @return void
*/
function my_custom_script() {
wp_enqueue_script( 'my-script', get_stylesheet_directory_uri() . '/js/my-script.js', array(), '1.0', true );
}
add_action( 'wp_enqueue_scripts', 'my_custom_script' );This loads my-script.js from your theme’s js folder whenever WordPress builds the front end script queue. Enqueuing lets WordPress handle dependencies and avoid loading the same file twice.
Advanced Example: Adding Custom Meta Boxes
A fuller example: adding a custom field to the post editor and saving it. Notice it uses two actions, one to build the box and one to save the value.
<?php
/**
* Adds a custom meta box to the "Edit Post" screen in the WordPress admin.
*
* @return void
*/
function add_custom_meta_box() {
add_meta_box( 'my_custom_meta_box', 'My Custom Meta Box', 'render_custom_meta_box', 'post', 'normal', 'high' );
}
add_action( 'add_meta_boxes', 'add_custom_meta_box' );
/**
* Renders the contents of the custom meta box.
*
* @param object $post The current post object.
* @return void
*/
function render_custom_meta_box( $post ) {
$custom_field = get_post_meta( $post->ID, '_custom_field', true );
?>
<label for="custom_field">Custom Field:</label>
<input type="text" name="custom_field" id="custom_field" value="<?php echo esc_attr( $custom_field ); ?>">
<?php
}
/**
* Saves the value of the custom meta field.
*
* @param int $post_id The ID of the post being saved.
* @return void
*/
function save_custom_meta( $post_id ) {
if ( isset( $_POST['custom_field'] ) ) {
update_post_meta( $post_id, '_custom_field', sanitize_text_field( $_POST['custom_field'] ) );
}
}
add_action( 'save_post', 'save_custom_meta' );add_meta_boxes builds the field, save_post writes it back. One honest caveat for production: a real save handler should also verify a nonce and check the user’s capabilities before writing, so a stray request can’t set your field. We’ve kept it short here to focus on the hook, but don’t ship it as is.
WordPress Filters
Filters are the other half. Where an action just runs code, a filter hands you a value, lets you change it, and expects you to hand it back. That last part is the rule people forget: a filter callback must return a value. Return nothing and you’ve wiped whatever you were handed, usually blanking the content you meant to tweak.
Hooking into Filters
You attach to a filter with add_filter(). Same shape as add_action(), but your function receives data and returns it.
<?php
/**
* Adds a custom filter to the the_content hook.
*
* @param string $content The content of the post or page.
* @return string The modified content of the post or page.
*/
function my_custom_filter( $content ) {
// Modify the content here.
return $content;
}
add_filter( 'the_content', 'my_custom_filter' );the_content runs your function right before post content is displayed. Change $content, return it, and readers see your version. Notice the return is doing the work here.
Removing Filters
Like actions, you can drop a filter another plugin or theme added, with remove_filter(). Same catch as before: the hook name and callback have to match what was registered.
<?php
/**
* Removes a custom filter from the excerpt_length hook.
*
* @return void
*/
function remove_excerpt_filter() {
// Your code here.
}
remove_filter( 'excerpt_length', 'my_custom_filter' );That takes my_custom_filter back off the excerpt_length hook so it stops changing the excerpt.
Basic Example of Using Filters
The simplest useful filter swaps one word for another in the content:
<?php
/**
* Adds a custom filter to the the_content hook that replaces "World" with "Universe".
*
* @param string $content The content of the post or page.
* @return string The modified content of the post or page.
*/
function my_custom_filter( $content ) {
$new_content = str_replace( 'World', 'Universe', $content );
return $new_content;
}
add_filter( 'the_content', 'my_custom_filter' );Every “World” in the post becomes “Universe” before it hits the page. And again, the changed value gets returned. That’s what makes it a filter.
Intermediate Example: Modifying Excerpt Length
Excerpt length is a filter too, so you set it by returning a number:
<?php
/**
* Modifies the excerpt length on a WordPress site.
*
* @param int $length The length of the excerpt.
* @return int The modified length of the excerpt.
*/
function my_custom_excerpt_length( $length ) {
return 50;
}
add_filter( 'excerpt_length', 'my_custom_excerpt_length' );WordPress hands you the current length, you return 50, and excerpts cap at 50 words. You ignore the incoming $length here, but a filter still has to return something, so you return the new number.
Advanced Example: Modifying a Query for Custom Post Types
Here’s one worth reading closely, because it’s a good reminder that actions and filters work as a pair. We’re changing which posts a query returns, but we do it through pre_get_posts, which is an action, not a filter. It hands you the query object by reference, you adjust it in place, and you return nothing. No add_filter() here on purpose.
<?php
/**
* Modifies the query for a custom post type archive.
*
* @param object $query The current query object.
* @return void
*/
function modify_custom_query( $query ) {
if ( $query->is_main_query() && ! is_admin() && is_post_type_archive( 'my_custom_post_type' ) ) {
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
$query->set( 'posts_per_page', 10 );
}
}
add_action( 'pre_get_posts', 'modify_custom_query' );This sorts a custom post type archive by title, ascending, ten per page. The guard clause matters: without the is_main_query() and ! is_admin() checks you’d change every query on the site, admin screens included. That’s a classic pre_get_posts mistake, so keep the guard.
Best Practices for Using Hooks and Filters
A few habits that save you pain later:
- Name functions for what they do: A clear name is documentation you get for free when you come back in six months.
- Write the PHPDoc: A short block explaining what a function takes and returns pays off the first time you or a teammate has to debug it.
- Set priority on purpose: If order matters, pick a number rather than trusting the default 10 to land where you want.
- Prefer named functions over closures: Anonymous functions are convenient, but you can’t
remove_action()orremove_filter()a closure you didn’t keep a reference to, and they’re harder to trace. - Clean up when you’re done: If a hook is no longer needed, remove it so it can’t cause conflicts down the line.
- Never touch core files: Everything above exists so you don’t have to. Extend through hooks, and updates stay painless.
- Always return in a filter: If you take one thing from this, it’s this. A filter that forgets to return will quietly break whatever it touches.
Wrapping Up
Actions run code at a moment; filters change a value and hand it back. Get that one distinction straight and most of WordPress opens up, because nearly everything it does is exposed through a hook somewhere.
The next piece in the series builds on this, so it’s worth getting comfortable here first. Wire up a couple of these on a test site, break them on purpose, and watch what happens. That’s how the difference between an action and a filter stops being a definition and starts being obvious.


