add_action()
Registers a callback to run when a named action fires — the signature, the callback forms, and why hooking too late fails silently.
add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): trueadd_action() attaches a callback to a named action. It has existed since WordPress 1.2.0 and returns true unconditionally: it does not check that the hook exists, or that the callback is callable. Any failure surfaces later, when the hook actually fires.
Callback forms
Any PHP callable works, and several forms are the same thing spelled differently:
'acme_render'— a plain function name.array( $this, 'render' )and[ $this, 'render' ]— identical; the second is short array syntax.array( 'Acme_Widget', 'render' )and'Acme_Widget::render'— identical for a static method, and WordPress reduces both to the same internal key, so either form can remove the other.$this->render( ... )— PHP 8.1 first-class callable syntax. It calls the same method, but it produces aClosureobject, which changes how the hook can be removed.
Hooking too late
An action fires once and moves on. Registering afterwards is the classic WordPress bug, and it is completely silent. A theme's functions.php is included by wp-settings.php after plugins_loaded and setup_theme have already fired, so add_action( 'plugins_loaded', ... ) written there never runs. Pick a hook still ahead of you: after_setup_theme or init from a theme, plugins_loaded from a plugin's main file.
Example
<?php
/**
* Plugin Name: Order Notifier
*/
class Acme_Order_Notifier {
public function register() {
// Object method. array( $this, 'notify' ) and [ $this, 'notify' ] are the same thing.
// save_post_{$post_type} passes 3 args, so accepted_args must be 3.
add_action( 'save_post_acme_order', array( $this, 'notify' ), 20, 3 );
// Static method. 'Acme_Order_Notifier::flush' is shorthand for
// array( 'Acme_Order_Notifier', 'flush' ) — WordPress stores both under one key.
add_action( 'shutdown', 'Acme_Order_Notifier::flush' );
}
public function notify( $post_id, $post, $update ) {
// save_post fires for autosaves and revisions too.
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
if ( ! $update ) { // false on the first real save of a new post.
wp_mail( get_option( 'admin_email' ), 'New order: ' . $post->post_title, '' );
}
}
public static function flush() {}
}
// This file runs before plugins_loaded fires, so the hook is still ahead of us.
add_action( 'plugins_loaded', function () {
( new Acme_Order_Notifier() )->register();
} );When to use it
- Register a custom post type or taxonomy on init from a plugin.
- Enqueue a stylesheet only on single posts, from wp_enqueue_scripts.
- Send a notification after a specific post type is saved.
- Add an admin notice when a required plugin dependency is missing.
More examples
The too-late bug, and the fix
Reach for this whenever a callback "does nothing" and you have already checked for typos. The relevant firing order in wp-settings.php is muplugins_loaded, plugins_loaded, setup_theme, theme functions.php, after_setup_theme, init, wp_loaded. did_action() turns the guess into a fact.
// In a theme's functions.php — BROKEN.
// wp-settings.php fires plugins_loaded, then setup_theme, and only THEN
// includes functions.php. Both hooks are already behind us; this never runs.
add_action( 'plugins_loaded', 'acme_boot' );
add_action( 'setup_theme', 'acme_boot' );
// In a theme's functions.php — CORRECT.
// after_setup_theme and init both fire after functions.php is included.
add_action( 'after_setup_theme', 'acme_boot' );
function acme_boot() {
add_theme_support( 'post-thumbnails' );
}
// Sanity check while debugging: did_action() is 0 if the hook has not fired yet.
if ( did_action( 'plugins_loaded' ) ) {
error_log( 'Too late to hook plugins_loaded from here.' );
}Closures and first-class callables
Shows why form (a) is the house style for anything shipped to other people. Closures read well but make your hook impossible for a site owner to unhook, which turns a small conflict into a support ticket. Use a closure only for hooks nobody else will ever need to remove.
class Acme_Assets {
public function register() {
// (a) Removable by anyone who can reach this object instance.
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) );
// (b) PHP 8.1 first-class callable syntax. Calls the same method, but it is a
// Closure, so remove_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ) )
// will NOT remove it — the internal keys differ.
add_action( 'wp_enqueue_scripts', $this->enqueue( ... ) );
// (c) An anonymous closure is effectively permanent unless you keep the reference.
$callback = function () {
wp_enqueue_style( 'acme-print', plugins_url( 'print.css', __FILE__ ), array(), '1.0' );
};
add_action( 'wp_enqueue_scripts', $callback );
$this->callback = $callback; // Keep it if anything may ever need to unhook it.
}
public function enqueue() {}
}Registering the hook only where it applies
Useful in any plugin big enough that loading everything everywhere costs measurable time. Note that is_admin() only tells you the request is an admin-side request, not that the current user is an administrator — it is a routing check, never a permission check.
add_action( 'plugins_loaded', 'acme_register_hooks' );
function acme_register_hooks() {
if ( is_admin() ) {
// admin_init runs on admin-ajax.php and admin-post.php as well as admin screens.
add_action( 'admin_init', 'acme_register_settings' );
add_action( 'admin_menu', 'acme_add_settings_page' );
return;
}
if ( wp_doing_cron() ) {
add_action( 'acme_nightly_sync', 'acme_run_sync' );
return;
}
// Front end only.
add_action( 'wp_enqueue_scripts', 'acme_enqueue_front_assets' );
add_action( 'template_redirect', 'acme_maybe_redirect_legacy_urls' );
}
Discussion