WordPress Theme Hooks, Actions and Filters Explained: A Practical Tutorial with Code Examples

If you have ever copied a parent theme’s header.php into your child theme just to add one line of HTML, this tutorial is for you. WordPress hooks, actions and filters exist precisely so you never have to touch a core template file again. They let you inject, modify or remove output from the outside, which means your changes survive updates and stay readable six months later.

Below is a hands-on walkthrough built around a custom theme: injecting content after the header, changing excerpt length, dequeuing parent theme scripts, creating your own theme hooks, plus a reference table of the theme hooks we actually use on client projects.

What is a hook in WordPress?

A hook is a named point in the WordPress execution flow where your code can plug in. WordPress core, plugins and themes place these named points all over the codebase using two functions:

  • do_action( 'hook_name', $args ) creates an action hook, an event you can react to.
  • apply_filters( 'hook_name', $value, $args ) creates a filter hook, a value you can rewrite before it is used.

You attach your own functions with add_action() and add_filter(). That is the whole model. Everything else is detail.

Actions vs filters, side by side

  Action Filter
Purpose Do something at a given moment Change a value before it is used
Receives Optional context arguments A value, always as first argument
Returns Nothing Must return a value
Typical use Echo markup, enqueue assets, register post types, send an email Excerpt length, body classes, menu markup, query arguments
Attach with add_action() add_filter()

Internally they are the same mechanism, filters are actions that return a value. But treat them as different in your head: forgetting the return in a filter is the single most common hook bug. Full details on https://wpruby.com.

wordpress code editor

The signature you need to memorise

add_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 );
add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 );
Parameter What it does
$hook Name of the hook you are listening to. A typo means silence, not an error.
$callback Function name, closure, or array( $object, 'method' ).
$priority Lower runs earlier. Default 10. Use a higher number to run after a parent theme or plugin.
$accepted_args How many arguments your callback wants. Ask for 2 and forget to declare it, PHP throws an ArgumentCountError.

Where to put your hook code

Order of preference for theme work:

  1. Child theme functions.php for anything presentation related in a parent/child setup.
  2. A dedicated include inside your custom theme, for example /inc/hooks.php, loaded from functions.php. A 600 line functions.php is technical debt.
  3. A small site plugin or mu-plugin for anything that must survive a theme switch: post types, shortcodes, redirects, tracking.

Prefix every callback with something unique (mud_ in the examples below) so you never collide with a plugin function.

Example 1: inject content after the header without editing header.php

The cleanest modern entry point is wp_body_open, which fires right after the opening <body> tag in any theme that calls wp_body_open().

<?php
/**
 * Announcement bar on the front page only.
 */
function mud_announcement_bar() {
    if ( ! is_front_page() ) {
        return;
    }

    echo '<div class="mud-announcement">Free markup audit for new projects this month.</div>';
}
add_action( 'wp_body_open', 'mud_announcement_bar' );

Need the content after the header markup rather than before it? Two reliable options:

Option A: use the parent theme’s own hook

Most serious parent themes expose their own actions, for example storefront_before_content, astra_header_after or generate_after_header. Search the parent theme folder for do_action( and you will get a full list in seconds. There is a detailed walkthrough elsewhere.

add_action( 'generate_after_header', 'mud_after_header_notice' );
function mud_after_header_notice() {
    if ( ! is_singular( 'post' ) ) {
        return;
    }
    echo '<p class="mud-reading-time">Estimated reading time: 6 minutes</p>';
}

Option B: add your own hook once, then never touch the template again

In your custom theme’s header.php, just below the closing </header> tag:

<?php do_action( 'mud_after_header' ); ?>

From that point on, every future addition happens in PHP files, not templates:

add_action( 'mud_after_header', 'mud_render_breadcrumbs', 10 );
add_action( 'mud_after_header', 'mud_render_hero', 20 );

That is the core idea behind hook driven theming: one template edit buys you unlimited future extensions.

wordpress code editor

Example 2: modify excerpt length and the read more link

excerpt_length and excerpt_more are the classic filter demo, and they show priority in action.

/**
 * Shorter excerpts on archives, longer on the blog home.
 */
function mud_excerpt_length( $length ) {
    if ( is_home() ) {
        return 40;
    }

    if ( is_archive() || is_search() ) {
        return 20;
    }

    return $length; // Always return the original when you do not care.
}
add_filter( 'excerpt_length', 'mud_excerpt_length', 999 );

/**
 * Replace the [...] string with a real link.
 */
function mud_excerpt_more( $more ) {
    return sprintf(
        ' &hellip; <a class="mud-more-link" href="%s">Continue reading</a>',
        esc_url( get_permalink() )
    );
}
add_filter( 'excerpt_more', 'mud_excerpt_more', 999 );

Why priority 999? Because parent themes almost always register their own excerpt_length filter at the default priority 10. Running later means your value is the last one returned, so yours wins. This trick solves about 80 percent of “my filter does nothing” tickets.

Example 3: dequeue parent theme scripts and styles

Parent themes love shipping a slider library, an icon font and three polyfills you will never use. Remove them on the wp_enqueue_scripts action at a priority higher than the one used to enqueue them.

function mud_dequeue_parent_assets() {
    // Scripts
    wp_dequeue_script( 'parenttheme-slider' );
    wp_deregister_script( 'parenttheme-slider' );

    // Styles
    wp_dequeue_style( 'parenttheme-fontawesome' );
    wp_deregister_style( 'parenttheme-fontawesome' );

    // Core block library CSS on pages that use no blocks
    if ( is_page_template( 'templates/landing.php' ) ) {
        wp_dequeue_style( 'wp-block-library' );
    }
}
add_action( 'wp_enqueue_scripts', 'mud_dequeue_parent_assets', 20 );

How to find the correct handle

Guessing handles wastes time. Dump the queue instead:

add_action( 'wp_print_scripts', function () {
    if ( ! current_user_can( 'manage_options' ) || ! WP_DEBUG ) {
        return;
    }
    global $wp_scripts, $wp_styles;
    error_log( 'Scripts: ' . implode( ', ', $wp_scripts->queue ) );
    error_log( 'Styles: ' . implode( ', ', $wp_styles->queue ) );
}, 100 );

Query Monitor gives you the same list in a nicer panel, including dependencies, which matters because dequeuing a handle that others depend on will break them.

The child theme enqueue pattern

function mud_enqueue_assets() {
    $theme = wp_get_theme();

    wp_enqueue_style(
        'mud-child',
        get_stylesheet_directory_uri() . '/style.css',
        array( 'parenttheme-main' ),
        $theme->get( 'Version' )
    );

    wp_enqueue_script(
        'mud-ui',
        get_stylesheet_directory_uri() . '/assets/js/ui.js',
        array(),
        $theme->get( 'Version' ),
        array( 'strategy' => 'defer', 'in_footer' => true )
    );
}
add_action( 'wp_enqueue_scripts', 'mud_enqueue_assets' );

Example 4: filters that change markup, not just text

Filters are not limited to strings. Arrays, objects and booleans all pass through them.

// Add a contextual class to the body tag.
function mud_body_classes( $classes ) {
    if ( is_singular() && has_post_thumbnail() ) {
        $classes[] = 'has-hero-image';
    }

    if ( ! is_active_sidebar( 'sidebar-1' ) ) {
        $classes[] = 'no-sidebar';
    }

    return $classes;
}
add_filter( 'body_class', 'mud_body_classes' );

// Append a CTA to single posts only, using two arguments.
function mud_append_cta( $content ) {
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    $cta = '<aside class="mud-cta">Need a pixel perfect theme? Get in touch.</aside>';

    return $content . $cta;
}
add_filter( 'the_content', 'mud_append_cta', 20 );

// Lazy load everything except the first image.
function mud_image_attributes( $attr, $attachment, $size ) {
    if ( ! isset( $attr['loading'] ) ) {
        $attr['loading'] = 'lazy';
    }
    $attr['decoding'] = 'async';

    return $attr;
}
add_filter( 'wp_get_attachment_image_attributes', 'mud_image_attributes', 10, 3 );

Note the 10, 3 at the end of the last one. Ask for three arguments, declare three parameters.

wordpress code editor

Example 5: build hooks into your own theme

If you sell or maintain a theme, expose hooks the way core does. Actions for placement, filters for values.

// In template-parts/hero.php
$title = apply_filters( 'mud_hero_title', get_the_title() );
$subtitle = apply_filters( 'mud_hero_subtitle', get_bloginfo( 'description' ) );

do_action( 'mud_before_hero' );
?>
<section class="mud-hero">
    <h1><?php echo esc_html( $title ); ?></h1>
    <p><?php echo esc_html( $subtitle ); ?></p>
    <?php do_action( 'mud_hero_inner' ); ?>
</section>
<?php
do_action( 'mud_after_hero' );

Rules of thumb when designing your own hooks:

  • Prefix with your theme slug so nothing collides.
  • Pass context as extra arguments, for example do_action( 'mud_after_hero', $post_id ).
  • Never rename a public hook after release, that is a breaking change.
  • Document them in a readme so other developers can find them.

Removing hooks added by a parent theme or plugin

remove_action() and remove_filter() need the exact same hook name, callback and priority used to register them, and they must run after registration.

// Simple function callback
add_action( 'after_setup_theme', function () {
    remove_action( 'parenttheme_footer', 'parenttheme_credits', 20 );
}, 11 );

// Class based callback registered as a singleton
add_action( 'init', function () {
    remove_filter( 'the_content', array( Parent_Theme_Content::get_instance(), 'inject_ads' ), 15 );
}, 20 );

Closures cannot be removed. If a parent theme used add_action( 'wp_footer', function () { ... } ), there is no handle to target. That is exactly why you should use named functions in code other people may need to override.

Reference table: the theme hooks you will actually use

Hook Type When it runs / what it filters Typical theme use
after_setup_theme Action Right after the theme loads add_theme_support, register menus, load textdomain
init Action WordPress loaded, user not yet queried for output Post types, taxonomies, shortcodes, removing other hooks
widgets_init Action Widget system boot register_sidebar()
pre_get_posts Action Before the query runs, object passed by reference Posts per page, custom ordering, excluding categories
wp_enqueue_scripts Action Front end asset queue Enqueue, dequeue, deregister CSS and JS
wp_head Action Inside <head> Preloads, meta tags, critical CSS
wp_body_open Action Just after the opening <body> Announcement bars, skip links, tag manager noscript
get_header / get_footer Action Before the header/footer template part loads Conditional markup around template parts
loop_start / loop_end Action Beginning and end of the loop Archive intros, inline ads, grid wrappers
wp_footer Action Before </body> Modals, deferred scripts, analytics
the_content Filter Post content HTML Append CTAs, author boxes, related posts
excerpt_length / excerpt_more Filter Word count and trailing string Archive card teasers
body_class / post_class Filter Array of CSS classes Layout variations without extra templates
nav_menu_css_class Filter Classes on each menu item Clean up bloated menu markup
wp_nav_menu_items Filter Full menu HTML Add a search icon or login link to a menu
document_title_parts Filter Array making up the <title> Custom title formats when no SEO plugin is used
template_include Filter Path of the template about to load Force a template for a specific condition
wp_get_attachment_image_attributes Filter img attributes array Lazy loading, decoding, custom classes
render_block Filter Final HTML of every block Wrap or alter block output in block themes
comment_form_default_fields Filter Comment form field markup Restyle or remove the URL field
wordpress code editor

Debugging hooks when nothing happens

Run through this checklist before blaming WordPress:

  1. Is the hook name spelled correctly? A wrong name fails silently.
  2. Did your filter return a value? No return means null, which usually means blank output.
  3. Is your file actually loaded? Drop a temporary error_log( 'loaded' ); at the top.
  4. Is another callback running after yours? Bump your priority to 999 and test again.
  5. Did the hook already fire? Registering on wp_footer from inside wp_footer is too late. Check with did_action( 'hook_name' ).
  6. Is anything attached at all? Use has_action() or has_filter(), which return the priority or false.

To see every callback attached to a hook:

add_action( 'wp_footer', function () {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    global $wp_filter;
    echo '<pre>';
    print_r( array_keys( (array) $wp_filter['the_content']->callbacks ) );
    echo '</pre>';
}, 999 );

Query Monitor remains the fastest tool for this: it lists fired hooks, attached callbacks and the component each one belongs to.

Do hooks still matter in block themes?

Yes. Block themes removed most PHP templates, but the PHP hook system is untouched. wp_enqueue_scripts, wp_body_open, wp_head, wp_footer, pre_get_posts and init all behave exactly the same. On top of that you get block specific filters such as render_block, block_type_metadata and should_load_separate_core_block_assets. If anything, hooks are more important in a block theme, because you no longer have template files to edit at all.

Common mistakes to avoid

  • Echoing inside a filter. Filters return, actions echo. Mixing them produces content in strange places.
  • Using closures for anything overridable. Nobody can remove them later.
  • Forgetting $accepted_args. On modern PHP this is a fatal error, not a notice.
  • Hooking too early. Calling template tags on plugins_loaded gives you incomplete data. Wait for init or wp.
  • No conditionals. A the_content filter without is_main_query() and in_the_loop() will also hit widgets, feeds and page builder previews.
  • Not escaping output. Use esc_html(), esc_url() and wp_kses_post() in every callback that prints markup.

FAQ

What is the difference between an action and a filter hook in WordPress?

An action receives data, does something with it and returns nothing. A filter receives a value, optionally modifies it and must return it. Use an action to output markup or trigger a process, use a filter to change a value before WordPress uses it.

How many types of hooks does WordPress have?

Exactly two: actions and filters. Lists of “4 types” or “7 types of hooks” you may find online usually refer to copywriting hooks in marketing, not to the WordPress Plugin API. Within WordPress you can also create custom hooks, but they are still just actions or filters.

How do I add a filter in WordPress?

Write a function that accepts the filtered value as its first parameter, modify it, return it, then register it with add_filter( 'hook_name', 'your_function', 10, 1 ); in your child theme functions.php or a site plugin.

Why is my hook not firing?

Most often the hook name is misspelled, your file is not loaded, the filter has no return, or another callback runs after yours at a higher priority. Test with has_filter(), did_action() and a temporary priority of 999.

Should hooks go in functions.php or in a plugin?

Presentation logic belongs in the theme, everything that must survive a theme switch belongs in a plugin or mu-plugin. Custom post types, redirects and analytics snippets should never live in functions.php.

Can I remove a hook added by a plugin?

Yes, with remove_action() or remove_filter(), using the identical callback and priority, and running your removal after the plugin registered it (usually on init or after_setup_theme with a later priority). Anonymous closures are the exception, they cannot be removed.

Where can I find a complete list of WordPress hooks?

The official Plugin Handbook and the Action and Filter references on developer.wordpress.org are the canonical sources. For day to day theme work, searching your own codebase for do_action( and apply_filters( is faster and gives you the hooks that actually exist in your project.

Key takeaways

  • Hooks let you extend WordPress without editing core, plugin or parent theme files.
  • Actions do, filters return. Always return in a filter.
  • Priority is your override tool: run later to win.
  • Declare $accepted_args whenever your callback needs more than one parameter.
  • Add your own do_action() and apply_filters() calls in custom themes so future changes stay out of templates.

Build your next theme around hooks from day one and updates stop being a risk. If you need a hook driven, update safe WordPress theme built from your design files, the team at MarkupDude does exactly that.

Leave a Comment