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
| Action | Filter | |
|---|---|---|
| Purpose | Do something at a moment | Change a value |
| Core fires it with | do_action( 'name', ...$args ) | apply_filters( 'name', $value, ...$args ) |
| You attach with | add_action() | add_filter() |
| Your callback returns | Nothing (the return value is ignored) | The value, changed or not |
| Examples | init, wp_footer, save_post | the_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
/**
* 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:
- When the plugin loads,
add_action()stores the callback namemyplugin_footer_noticeagainst the hookwp_footer. Nothing is printed yet. - Much later, the theme calls
wp_footer(), which callsdo_action( 'wp_footer' ). - 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:
// 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:
// wp-includes (core, simplified)
$excerpt_length = (int) apply_filters( 'excerpt_length', 55 );
To change it, attach a callback that returns the new value:
// 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:
// 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:
// 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.
// 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:
// 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_filtersordo_actioncalls. - Search core. In your local site, search
wp-includesfor the text you see on screen or forapply_filters( '. - Query Monitor. Its Hooks panel lists every action that fired on the current page and the callbacks attached to each.
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:
// 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()andadd_filter(); core fires hooks withdo_action()andapply_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.