Code HeavenCode Heaven
Back to Blog
Guide5/19/20269 min

WordPress Hooks Explained: The Complete Guide to Actions and Filters

By Code Heaven

Every WordPress plugin you've ever installed, every theme customization you've made, and every snippet you've dropped into functions.php relies on one mechanism: hooks.

WordPress hooks are the backbone of the platform's extensibility. They let you inject your own code at specific points in WordPress's execution without ever modifying core files. Understanding WordPress hooks, actions, and filters isn't just useful -- it's essential for anyone building anything beyond a basic blog.

WordPress core itself contains over 2,500 hooks. The plugin ecosystem -- responsible for everything from contact forms to full e-commerce platforms -- is built entirely on this hook system. When you update WordPress and your customizations survive, that's hooks doing their job. When you activate a plugin and it seamlessly integrates with your theme, that's hooks again.

This guide breaks down how hooks work, walks through practical examples of both actions and filters, shows you how to create your own custom hooks, and covers the debugging techniques and best practices that separate clean WordPress code from the kind that breaks on every update.

Actions vs Filters: The Core Difference

WordPress has two types of hooks, and the distinction is straightforward:

Actions execute code at a specific point. They do things -- send an email, enqueue a script, save data to the database. They don't return anything meaningful.

Filters modify data and pass it along. They change things -- alter post content, adjust query parameters, rewrite URLs. They always receive a value and must return a value.

Think of it this way: actions are like event listeners ("when X happens, also do Y"), while filters are like assembly line workers ("here's the data, modify it and pass it back").

// Action: DO something when a post is published add_action('publish_post', 'notify_admin_of_new_post'); // Filter: MODIFY the excerpt length add_filter('excerpt_length', 'custom_excerpt_length');

Both use the same underlying system, but mixing them up is one of the most common mistakes developers make. Forget to return a value from a filter callback, and you'll silently break things.

How Hooks Work Under the Hood

When WordPress executes, it hits predefined hook points scattered throughout its codebase. At each point, it checks: "Has anyone registered a callback for this hook?" If yes, it runs those callbacks.

The registration functions are simple:

add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ); add_filter( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );

The priority parameter controls execution order. Lower numbers run first. The default is 10, so if you need your code to run before other callbacks, use a lower number. Need it to run after? Go higher.

// Runs first (priority 5) add_action('init', 'early_init_tasks', 5); // Runs at default priority (10) add_action('init', 'normal_init_tasks'); // Runs last (priority 99) add_action('init', 'late_init_tasks', 99);

The accepted_args parameter tells WordPress how many arguments to pass to your callback. This matters for hooks that provide multiple pieces of context.

On the triggering side, WordPress core (or plugins/themes) fires hooks with:

do_action('hook_name', $arg1, $arg2); // Fire an action $value = apply_filters('hook_name', $value, $arg1); // Apply filters

That's the entire system. Registration and execution. Everything else builds on top of this.

Practical Action Hook Examples

wp_enqueue_scripts -- Loading Assets Properly

This is the correct way to add CSS and JavaScript to your theme. Never hardcode <script> or <link> tags in your header.

add_action('wp_enqueue_scripts', 'codeheaven_load_assets'); function codeheaven_load_assets() { wp_enqueue_style( 'codeheaven-style', get_stylesheet_directory_uri() . '/css/main.css', array(), '1.0.0' ); wp_enqueue_script( 'codeheaven-app', get_stylesheet_directory_uri() . '/js/app.js', array('jquery'), '1.0.0', true // Load in footer ); }

Using wp_enqueue_scripts ensures proper dependency management and prevents duplicate loading. WordPress will only load jQuery once, even if ten plugins declare it as a dependency. It also gives WordPress the ability to output scripts in the correct order based on their dependency tree, and enables performance features like deferred or async loading.

For admin-specific assets, use the admin_enqueue_scripts hook instead. It works identically but only fires on dashboard pages, keeping your admin scripts out of the frontend.

init -- Registering Custom Post Types and Taxonomies

The init hook fires after WordPress finishes loading but before headers are sent. It's the right place to register post types, taxonomies, and shortcodes.

add_action('init', 'codeheaven_register_portfolio'); function codeheaven_register_portfolio() { register_post_type('portfolio', array( 'labels' => array( 'name' => 'Portfolio', 'singular_name' => 'Project', ), 'public' => true, 'has_archive' => true, 'supports' => array('title', 'editor', 'thumbnail'), 'menu_icon' => 'dashicons-portfolio', 'rewrite' => array('slug' => 'portfolio'), )); }

save_post -- Running Logic When Content Is Saved

The save_post hook fires whenever a post is created or updated. It's commonly used to save custom field data, trigger cache invalidation, or sync data to external services.

add_action('save_post', 'codeheaven_save_custom_meta', 10, 3); function codeheaven_save_custom_meta( $post_id, $post, $update ) { // Skip autosaves and revisions if ( defined('DOING_AUTOSAVE') && DOING_AUTOSAVE ) return; if ( wp_is_post_revision( $post_id ) ) return; // Verify nonce if ( ! isset($_POST['codeheaven_meta_nonce']) ) return; if ( ! wp_verify_nonce($_POST['codeheaven_meta_nonce'], 'codeheaven_save_meta') ) return; // Save the data if ( isset($_POST['project_url']) ) { update_post_meta( $post_id, '_project_url', sanitize_url($_POST['project_url']) ); } }

Notice the guards at the top. Without them, your code runs during autosaves, revision creation, and potentially without proper authorization.

wp_footer -- Injecting Code Before the Closing Body Tag

Need to add a tracking script, modal HTML, or any markup before </body>? Use wp_footer.

add_action('wp_footer', 'codeheaven_add_back_to_top'); function codeheaven_add_back_to_top() { echo '<button id="back-to-top" aria-label="Back to top">↑</button>'; }

Practical Filter Hook Examples

the_content -- Modifying Post Content

One of WordPress's most powerful filters. Every piece of post content passes through the_content before rendering.

add_filter('the_content', 'codeheaven_add_cta_after_post'); function codeheaven_add_cta_after_post( $content ) { if ( is_single() && in_the_loop() && is_main_query() ) { $cta = '<div class="post-cta">'; $cta .= '<p>Found this useful? Subscribe for more WordPress tutorials.</p>'; $cta .= '</div>'; $content .= $cta; } return $content; }

The conditional checks are critical. Without is_single() and is_main_query(), your CTA would appear in excerpts, widgets, REST API responses, and every other place content is rendered.

wp_mail -- Customizing WordPress Emails

Override email headers, recipients, or content system-wide.

add_filter('wp_mail_from', 'codeheaven_mail_from'); add_filter('wp_mail_from_name', 'codeheaven_mail_from_name'); function codeheaven_mail_from( $email ) { return '[email protected]'; } function codeheaven_mail_from_name( $name ) { return 'Code Heaven'; }

login_redirect -- Controlling Where Users Go After Login

By default, WordPress sends users to the admin dashboard. You can redirect different roles to different pages.

add_filter('login_redirect', 'codeheaven_role_based_redirect', 10, 3); function codeheaven_role_based_redirect( $redirect_to, $requested, $user ) { if ( ! is_wp_error($user) && in_array('subscriber', $user->roles) ) { return home_url('/account/'); } return $redirect_to; }

excerpt_length -- Adjusting Excerpt Word Count

A simple but frequently needed filter.

add_filter('excerpt_length', 'codeheaven_excerpt_length'); function codeheaven_excerpt_length( $length ) { return 30; // Default is 55 words }

Creating Your Own Custom Hooks

Custom hooks turn your theme or plugin into an extensible platform. Other developers (or your future self) can hook into your code without modifying it.

When to Create Custom Hooks

Create a custom hook when you want to allow modification at a specific point. Common scenarios include: before or after a template section renders, when processing form data, when generating output that others might want to customize, or when your plugin produces data that other plugins could benefit from modifying.

The general rule: if you're building something that will be distributed (a public plugin or parent theme), add hooks liberally. If it's a one-off site build, only add hooks at points where you know you'll need flexibility later. Over-hooking a private project just adds complexity with no benefit.

Custom Action Example

// In your theme template function codeheaven_render_hero() { do_action('codeheaven_before_hero'); echo '<section class="hero">'; echo '<h1>' . get_bloginfo('name') . '</h1>'; echo '</section>'; do_action('codeheaven_after_hero'); } // In a child theme or plugin -- hook into it add_action('codeheaven_after_hero', 'add_hero_breadcrumbs'); function add_hero_breadcrumbs() { echo '<nav class="breadcrumbs">Home > ' . get_the_title() . '</nav>'; }

Custom Filter Example

// In your plugin function codeheaven_get_pricing_tiers() { $tiers = array( 'basic' => array('name' => 'Basic', 'price' => 9), 'pro' => array('name' => 'Pro', 'price' => 29), 'agency' => array('name' => 'Agency', 'price' => 99), ); // Let other code modify the pricing tiers return apply_filters('codeheaven_pricing_tiers', $tiers); } // Elsewhere -- modify the tiers add_filter('codeheaven_pricing_tiers', 'add_enterprise_tier'); function add_enterprise_tier( $tiers ) { $tiers['enterprise'] = array('name' => 'Enterprise', 'price' => 299); return $tiers; }

Prefix your custom hook names with your project namespace (like codeheaven_) to avoid collisions with other plugins. Without a prefix, generic names like before_header or modify_settings will inevitably clash with another plugin or theme that had the same idea.

Debugging Hooks: Tools and Techniques

Query Monitor Plugin

Query Monitor is the single best debugging tool for hooks. It shows every hook that fires on a page, which callbacks are attached, their priorities, and execution time. Install it on any development site.

Its Hooks & Actions panel lists every hook that fired during the current request, along with the number of callbacks and total time spent. If a page is slow, you can immediately identify which hook callbacks are the bottleneck. It also integrates with the admin toolbar, so you get this data without leaving the frontend.

List All Callbacks on a Hook

function codeheaven_debug_hook( $hook_name ) { global $wp_filter; if ( ! isset($wp_filter[$hook_name]) ) { return 'No callbacks registered.'; } echo '<pre>'; foreach ( $wp_filter[$hook_name]->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $callback ) { $func = $callback['function']; if ( is_array($func) ) { $func = get_class($func[0]) . '::' . $func[1]; } echo "Priority {$priority}: {$func}\n"; } } echo '</pre>'; } // Usage: codeheaven_debug_hook('the_content');

Temporarily Remove a Hook

When debugging conflicts, you can remove specific callbacks:

// Remove a specific function from a hook remove_action('wp_head', 'wp_generator'); remove_filter('the_content', 'wpautop'); // Remove a class method (you need the same instance) remove_action('init', array($plugin_instance, 'method_name'), 10);

The priority must match the original add_action/add_filter call, or the removal silently fails. This silent failure is a common source of frustration -- if remove_action doesn't seem to be working, double-check that you're passing the exact same priority that was used when the hook was registered. You also need to call remove_action or remove_filter after the original add_action/add_filter has run, which usually means hooking the removal into a later hook or using a higher priority on the same hook.

The did_action() Function

Check whether an action has already fired, which is useful for conditional logic and debugging race conditions:

if ( did_action('init') ) { // Safe to use functions that depend on init having fired }

Common Mistakes and Best Practices

1. Forgetting to Return in Filters

This is the number one hook-related bug. Filters must return a value. If you don't, the filtered data becomes null.

// WRONG -- breaks the_content add_filter('the_content', function($content) { // Do something but forget to return $content .= '<p>Extra content</p>'; }); // CORRECT add_filter('the_content', function($content) { $content .= '<p>Extra content</p>'; return $content; });

2. Using the Wrong Hook

Loading scripts in init instead of wp_enqueue_scripts. Adding admin-only code without checking is_admin(). Registering post types in after_setup_theme instead of init. Each hook has a purpose -- use the right one.

3. Ignoring Priority

If your code needs to run after another plugin's callback, don't just hope the default priority works. Set it explicitly.

// Override a plugin that sets excerpt length at priority 10 add_filter('excerpt_length', 'my_excerpt_length', 999);

4. Not Cleaning Up

If your plugin adds hooks, remove them on deactivation where appropriate. Callbacks that modify the database or schedule cron events should always be cleaned up.

register_deactivation_hook(__FILE__, 'codeheaven_deactivate'); function codeheaven_deactivate() { wp_clear_scheduled_hook('codeheaven_daily_cleanup'); }

5. Hooking Too Early

Trying to use WordPress functions before they're available:

// WRONG -- runs immediately, before WordPress is ready add_action('init', my_function_that_doesnt_exist_yet()); // CORRECT -- passes the function name as a callback add_action('init', 'my_function_that_doesnt_exist_yet');

6. Performance: Keep Callbacks Lean

Hooks like the_content and init fire frequently. Heavy database queries or API calls inside these callbacks will slow down every page load. Cache results where possible and bail early when your code doesn't need to run.

add_filter('the_content', 'codeheaven_maybe_add_related'); function codeheaven_maybe_add_related( $content ) { // Bail early if not needed if ( ! is_single() || ! is_main_query() ) { return $content; } // Use transient cache for expensive queries $related = get_transient('related_posts_' . get_the_ID()); if ( false === $related ) { $related = get_related_posts(); // Expensive query set_transient('related_posts_' . get_the_ID(), $related, HOUR_IN_SECONDS); } return $content . $related; }

Wrapping Up

WordPress hooks are a simple concept with enormous depth. Actions let you execute code at the right moment. Filters let you reshape data as it flows through WordPress. Together, they make WordPress one of the most extensible platforms ever built.

The pattern is always the same: register a callback with add_action or add_filter, write a function that does the work, and let WordPress call it at the right time. Master this pattern and you can customize virtually every aspect of WordPress without touching a single core file.

Start with the hooks covered in this guide -- wp_enqueue_scripts, init, the_content, and save_post will cover the majority of real-world use cases. As you get comfortable, explore the WordPress Hook Reference to discover the hundreds of other hooks available throughout the platform.

When you read plugin or theme source code, look for do_action and apply_filters calls. Each one is an extension point you can use. The best WordPress developers don't just use hooks -- they think in hooks. They design their code around the same extensibility patterns that make WordPress itself so adaptable. Once that mental model clicks, you'll never look at a WordPress project the same way again.