Actions and filters: introduction to hooks

Run your own code at the right moment with actions, and change WordPress values with filters.

  • Intermediate
  • 7 min read
  • Updated 17 Oct 2026

What you will learn

  • Attach code to an action
  • Change a value with a filter
  • Find the right hook for a task

Before you start

Hooks are how WordPress lets you change its behaviour without editing its files. Core, themes and plugins all announce named moments and values, and your code attaches to them. Almost every line of WordPress code you write will start from a hook.

Two kinds of hook

ActionFilter
PurposeDo something at a momentChange a value
Core fires it withdo_action( 'name', ...$args )apply_filters( 'name', $value, ...$args )
You attach withadd_action()add_filter()
Your callback returnsNothing (the return value is ignored)The value, changed or not
Examplesinit, wp_footer, save_postthe_content, the_title, excerpt_length

Think of an action as an event ("this just happened") and a filter as a checkpoint that every value passes through ("here is the value, give it back").

Your first action

wp_footer fires when a theme calls wp_footer() just before </body>. This plugin prints a small notice there:

PHP
<?php
/**
 * Plugin Name: My Plugin
 * Text Domain: myplugin
 */

defined( 'ABSPATH' ) || exit;

add_action( 'wp_footer', 'myplugin_footer_notice' );

function myplugin_footer_notice() {
    echo '<p class="myplugin-notice">' . esc_html__( 'Prices include VAT.', 'myplugin' ) . '</p>';
}

What happens:

  1. When the plugin loads, add_action() stores the callback name myplugin_footer_notice against the hook wp_footer. Nothing is printed yet.
  2. Much later, the theme calls wp_footer(), which calls do_action( 'wp_footer' ).
  3. WordPress runs every callback attached to wp_footer, including yours.

The order in the file does not matter: you can call add_action() before or after defining the function, because PHP defines named functions when the file is compiled.

esc_html__() translates a string and escapes it for HTML in one step. You will learn escaping and translation properly later in the course. For now, escape everything you print.

Actions with arguments

Many actions pass data to your callback. save_post passes the post ID, the post object and whether it is an update. You choose how many you accept:

PHP
// wp-content/plugins/myplugin/myplugin.php
add_action( 'save_post', 'myplugin_log_save', 10, 2 );

function myplugin_log_save( $post_id, $post ) {
    if ( wp_is_post_revision( $post_id ) ) {
        return;
    }

    error_log( sprintf( 'Saved %s #%d', $post->post_type, $post_id ) );
}

The third argument to add_action() is the priority (default 10) and the fourth is the number of arguments to pass (default 1). If you leave out the 2, your function receives only $post_id and PHP throws an error about the missing $post. The next hooks lesson covers priority and arguments in depth.

Your first filter

excerpt_length controls how many words an automatic excerpt has. Core runs the value through a filter:

PHP
// wp-includes (core, simplified)
$excerpt_length = (int) apply_filters( 'excerpt_length', 55 );

To change it, attach a callback that returns the new value:

PHP
// wp-content/plugins/myplugin/myplugin.php
add_filter( 'excerpt_length', 'myplugin_excerpt_length' );

function myplugin_excerpt_length( $length ) {
    return 30;
}

A filter that changes text, such as the_content, receives the text and must return it:

PHP
// wp-content/plugins/myplugin/myplugin.php
add_filter( 'the_content', 'myplugin_add_reading_note' );

function myplugin_add_reading_note( $content ) {
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    $note = '<p class="myplugin-note">' . esc_html__( 'Thanks for reading.', 'myplugin' ) . '</p>';

    return $content . $note;
}

The guard clause returns the content unchanged everywhere except the main content of a single post. the_content runs in many places, including feeds and widgets, so checking context is a habit worth forming.

Every filter callback must return a value, on every path. A filter that forgets to return hands back null, and the post content, title or setting it filtered becomes empty.

Callbacks: named functions, methods and closures

A callback can be any PHP callable:

PHP
// A named function.
add_action( 'init', 'myplugin_register_types' );

// A static class method.
add_action( 'init', array( 'Myplugin_Types', 'register' ) );

// An object method.
$myplugin_admin = new Myplugin_Admin();
add_action( 'admin_menu', array( $myplugin_admin, 'add_pages' ) );

// A closure.
add_filter( 'excerpt_more', static function ( $more ) {
    return '…';
} );

Closures are convenient, but other code cannot remove them, because removal needs the same callable and nobody else has a reference to your closure. Prefer named functions or methods for anything another developer might want to unhook.

WordPress also ships small helper callbacks for common filter values: __return_true, __return_false, __return_empty_array, __return_zero and __return_null.

PHP
// Turn off the "Comments" feed links in the document head.
add_filter( 'feed_links_show_comments_feed', '__return_false' );

Removing a callback

remove_action() and remove_filter() detach a callback. You must pass the same hook, the same callable and the same priority it was added with, and you must remove it after it was added:

PHP
// wp-content/plugins/myplugin/myplugin.php
add_action( 'init', 'myplugin_remove_emoji_script' );

function myplugin_remove_emoji_script() {
    remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
}

Core adds print_emoji_detection_script to wp_head at priority 7, so the removal uses 7 too.

Finding the right hook

Knowing that a hook exists is half the job. Ways to find one:

  • The Code Reference. developer.wordpress.org lists every core hook with its parameters and the file where it fires. Search for what you want to change, such as "excerpt" or "login".
  • Read the function. If you know which core function produces the output you want to change, open its source in the Code Reference and look for apply_filters or do_action calls.
  • Search core. In your local site, search wp-includes for the text you see on screen or for apply_filters( '.
  • Query Monitor. Its Hooks panel lists every action that fired on the current page and the callbacks attached to each.
Shell
grep -rn "apply_filters( 'excerpt_" wp-includes/

When you have a candidate, check three things: when it fires, what arguments it passes, and, for a filter, what type of value you must return.

Hook naming

Core hook names are plain, such as init or the_title. Some are dynamic: part of the name comes from a variable. For example save_post_{$post->post_type} fires as save_post_page for pages, which saves you checking the post type yourself:

PHP
// wp-content/plugins/myplugin/myplugin.php
add_action( 'save_post_page', 'myplugin_page_saved' );

function myplugin_page_saved( $post_id ) {
    delete_transient( 'myplugin_page_list' );
}

Summary

  • Actions run code at a moment; filters receive a value and must return it.
  • Attach with add_action() and add_filter(); core fires hooks with do_action() and apply_filters().
  • The third parameter is priority, the fourth is how many arguments your callback accepts.
  • Prefer named functions or methods so others can remove your callbacks.
  • Find hooks in the Code Reference, in core source and with Query Monitor.

In the next lesson you will use the Loop and template tags to display posts in a classic theme.

Further reading