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.

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:
- Child theme
functions.phpfor anything presentation related in a parent/child setup. - A dedicated include inside your custom theme, for example
/inc/hooks.php, loaded fromfunctions.php. A 600 linefunctions.phpis technical debt. - 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.

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(
' … <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.

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 |

Debugging hooks when nothing happens
Run through this checklist before blaming WordPress:
- Is the hook name spelled correctly? A wrong name fails silently.
- Did your filter return a value? No return means
null, which usually means blank output. - Is your file actually loaded? Drop a temporary
error_log( 'loaded' );at the top. - Is another callback running after yours? Bump your priority to 999 and test again.
- Did the hook already fire? Registering on
wp_footerfrom insidewp_footeris too late. Check withdid_action( 'hook_name' ). - Is anything attached at all? Use
has_action()orhas_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_loadedgives you incomplete data. Wait forinitorwp. - No conditionals. A
the_contentfilter withoutis_main_query()andin_the_loop()will also hit widgets, feeds and page builder previews. - Not escaping output. Use
esc_html(),esc_url()andwp_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_argswhenever your callback needs more than one parameter. - Add your own
do_action()andapply_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.
