
WordPress hooks are the backbone of every plugin and theme. They let you run your own code at specific points in WordPress’s execution cycle — without touching core files. There are two types: actions and filters. Understanding the difference is the single most important concept in WordPress plugin development.
The short version: an action does something at a point in time. A filter modifies something and returns the modified value. Every hook in WordPress is one or the other.
An action hook fires at a specific point — when a post is saved, when the page header loads, when a user logs in. You attach your function to it with add_action(). Your function runs, does its work, and returns nothing.
// Basic action: add something to the footer
add_action( 'wp_footer', 'my_plugin_footer_script' );
function my_plugin_footer_script() {
echo '';
}
PHPThe add_action() function signature:
add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );
PHPPriority controls execution order when multiple callbacks are attached to the same hook. Lower number = runs earlier. Default is 10. If two callbacks have the same priority, they run in the order they were registered.
// Runs before most other callbacks on init
add_action( 'init', 'my_early_init', 1 );
// Runs after most other callbacks on init
add_action( 'init', 'my_late_init', 99 );
function my_early_init() {
// fires first
}
function my_late_init() {
// fires last
}
PHPSome action hooks pass data to your callback. You need to declare how many arguments you want to receive using the fourth parameter of add_action():
// The save_post hook passes 3 arguments: $post_id, $post, $update
add_action( 'save_post', 'my_save_post_handler', 10, 3 );
function my_save_post_handler( int $post_id, WP_Post $post, bool $update ) {
if ( $update ) {
// Post is being updated, not created
error_log( 'Post updated: ' . $post->post_title );
}
}
PHPA filter hook intercepts a value, lets you modify it, and expects you to return something back. The key rule: always return a value in a filter callback. If you forget to return, the filtered value becomes null and you’ll break things silently.
// Filter: modify the excerpt length
add_filter( 'excerpt_length', 'my_custom_excerpt_length' );
function my_custom_excerpt_length( int $length ): int {
return 30; // Return 30 words instead of the default 55
}
PHPThe add_filter() function signature is identical to add_action():
add_filter( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );
PHP// Modify post content before display
add_filter( 'the_content', 'my_append_signature' );
function my_append_signature( string $content ): string {
if ( ! is_single() ) {
return $content; // Don't modify on archive pages
}
return $content . '';
}
// Modify the login error message (hide which field is wrong for security)
add_filter( 'login_errors', 'my_generic_login_error' );
function my_generic_login_error( string $error ): string {
return 'Invalid credentials. Please try again.';
}
// Change the number of posts per page on a specific post type archive
add_filter( 'pre_get_posts', 'my_portfolio_posts_per_page' );
function my_portfolio_posts_per_page( WP_Query $query ): void {
if ( ! is_admin() && $query->is_main_query() && $query->is_post_type_archive( 'portfolio' ) ) {
$query->set( 'posts_per_page', 12 );
}
// Note: pre_get_posts is an action that receives the query by reference,
// but it's also used as a filter pattern — no return needed here
}
PHP| Actions | Filters | |
|---|---|---|
| Purpose | Run code at a point in time | Modify a value before it’s used |
| Return value | Not required (ignored) | Required — always return something |
| Function | add_action() | add_filter() |
| Triggered by | do_action() | apply_filters() |
| Common examples | init, wp_head, save_post | the_content, the_title, excerpt_length |
| Side effects | Expected (echoing, DB writes) | Should be avoided |
You can remove any action or filter that another plugin or theme registered — as long as you know the exact callback name and priority.
// Remove a named function hook
remove_action( 'wp_head', 'wp_generator' ); // Remove WP version from head
remove_filter( 'the_content', 'wpautop' ); // Remove auto paragraph wrapping
// The priority must match what was used in add_action/add_filter
// Default priority is 10 — if the original used a different priority, match it:
remove_action( 'woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10 );
PHPRemoving hooks from class methods is slightly different — you need a reference to the object:
// If a plugin registered a hook like this:
// add_action( 'init', [ $this, 'setup' ] );
// You need the object instance to remove it
global $my_plugin_instance;
remove_action( 'init', [ $my_plugin_instance, 'setup' ] );
// Anonymous functions CANNOT be removed — avoid them for public hooks
// This cannot be undone by other code:
add_action( 'init', function() {
// anonymous — irremovable
} );
PHPIf you’re building a plugin for others to extend, you should add your own hooks. This is what makes plugins like WooCommerce so extensible — they fire hundreds of custom actions and filters that other developers hook into.
// In your plugin — fire a custom action
function my_plugin_process_order( int $order_id ): void {
// ... do processing ...
// Let other code run after order processing
do_action( 'my_plugin_after_order_processed', $order_id );
}
// In your plugin — apply a custom filter
function my_plugin_get_price( float $base_price, int $product_id ): float {
// Allow other code to modify the price
return apply_filters( 'my_plugin_product_price', $base_price, $product_id );
}
// Another plugin/theme can now hook into yours:
add_action( 'my_plugin_after_order_processed', function( int $order_id ) {
// Send a Slack notification, update a CRM, etc.
} );
add_filter( 'my_plugin_product_price', function( float $price, int $product_id ): float {
return $price * 0.9; // Apply 10% discount
}, 10, 2 );
PHPName your hooks with a unique prefix matching your plugin slug — myplugin_ — to avoid collisions with WordPress core or other plugins.
Most modern plugins use OOP. Registering hooks inside a class is straightforward with [ $this, 'method_name' ]:
class My_Plugin {
public function __construct() {
add_action( 'init', [ $this, 'register_post_types' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
add_filter( 'the_content', [ $this, 'filter_content' ] );
}
public function register_post_types(): void {
// register_post_type() calls here
}
public function enqueue_assets(): void {
wp_enqueue_style( 'my-plugin', plugin_dir_url( __FILE__ ) . 'assets/style.css' );
}
public function filter_content( string $content ): string {
// modify and return
return $content;
}
}
// Instantiate once
new My_Plugin();
PHPFor static methods, use the class name string instead of $this:
add_action( 'init', [ 'My_Plugin', 'static_method' ] );
// or shorthand:
add_action( 'init', 'My_Plugin::static_method' );
PHP| Hook | When it fires | Common use |
|---|---|---|
init | After WordPress loads, before headers sent | Register CPTs, taxonomies, rewrite rules |
wp_enqueue_scripts | Front-end asset loading | Enqueue CSS/JS for themes and plugins |
admin_enqueue_scripts | Admin asset loading | Enqueue admin-only CSS/JS |
wp_head | Inside tag | Add meta tags, inline styles |
wp_footer | Before closing | Add inline scripts, tracking code |
save_post | After a post is saved | Save custom meta, trigger notifications |
admin_menu | Admin menu is built | Register admin pages and subpages |
plugins_loaded | All plugins are loaded | Plugin compatibility checks |
template_redirect | Before template loads | Redirects, access control |
wp_ajax_{action} | Ajax request (logged in) | Handle admin-side AJAX |
wp_ajax_nopriv_{action} | Ajax request (public) | Handle front-end AJAX |
| Hook | Filters | Common use |
|---|---|---|
the_content | Post content before display | Append/prepend content, shortcode processing |
the_title | Post title | Add prefixes, icons, labels |
excerpt_length | Excerpt word count | Change default 55-word limit |
excerpt_more | Excerpt trailing text | Change “[…]” to a Read More link |
body_class | Array of body CSS classes | Add conditional classes |
upload_mimes | Allowed upload file types | Add SVG, custom file types |
wp_mail | Email args before sending | Change from address, add CC/BCC |
login_redirect | URL after login | Role-based login redirects |
cron_schedules | WP-Cron intervals | Add custom cron intervals |
When hooks aren’t firing as expected, these techniques help:
// Check if a function is hooked to an action
if ( has_action( 'init', 'my_function' ) ) {
echo 'my_function is hooked to init';
}
// Get the priority it's registered at
$priority = has_filter( 'the_content', 'my_content_filter' );
// Returns false if not hooked, or the priority integer if it is
// Check what's hooked to a given action (inspect the global)
global $wp_filter;
var_dump( $wp_filter['init'] ); // Shows all callbacks and priorities
// The best approach: install Query Monitor plugin
// It shows all hooks fired on the current request, their callbacks, and priorities
PHPnull and breaks the output silently.remove_action() must use the same priority as the original add_action() or it won’t work.get_post() inside an init callback, the global query isn’t set up yet. Use wp or template_redirect for anything that needs query data.save_post fires multiple times per save (once for the post, once for each revision). Always add a guard: if ( wp_is_post_revision( $post_id ) ) return;An action runs your code at a specific point in WordPress’s execution and returns nothing. A filter intercepts a value, lets you modify it, and must return something. Use add_action() when you want to do something; use add_filter() when you want to change something.
Technically yes — WordPress uses the same internal registry for both. But you should use the semantically correct function: add_action() for actions and add_filter() for filters. It makes your code readable and is expected by other developers.
Priority is an integer (default 10) that controls the order callbacks run when multiple functions are attached to the same hook. Lower numbers run first. If two callbacks share the same priority, they run in registration order. Use a higher priority (e.g. 99) to run after core and other plugins, or a lower priority (e.g. 1) to run before them.
The WordPress Developer Reference lists all core hooks. The Query Monitor plugin shows every hook fired on any given page load, including which callbacks are attached. For WooCommerce hooks specifically, the WooCommerce hooks documentation is the best reference.
Add your own hooks wherever another developer might reasonably want to extend or modify your plugin’s behaviour — before and after major operations, and around any value that should be customisable. This is what makes your plugin “developer-friendly” and is standard practice for any plugin you plan to sell or distribute.

I’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.
Learn More