Creating Your Own Hooks
do_action() and apply_filters() let your own plugin be extended — and every hook you ship becomes public API you have to keep.
do_action( string $hook_name, mixed ...$arg ): void | apply_filters( string $hook_name, mixed $value, mixed ...$args ): mixedYou create a hook by calling do_action() or apply_filters(). There is no registration step: the name exists because you fired it. do_action( $hook_name, ...$arg ) announces that something happened and discards return values. apply_filters( $hook_name, $value, ...$args ) passes a value through and returns whatever comes back.
Naming and documenting
Prefix every hook with your plugin's slug — acme_order_placed, not order_placed — because the namespace is global and flat. Put a docblock immediately above the call with @since and a @param per argument; this is what the Code Reference reads, and it is what someone writing $accepted_args against your hook will count.
A hook is a contract
Once released, the name, the argument order and the argument types are public API. Adding a parameter at the end is safe. Inserting one in the middle, renaming the hook, or changing what it passes will break every callback silently. Since WordPress 4.6 you can retire a hook properly with do_action_deprecated() and apply_filters_deprecated(), which fire the old hook and raise a deprecation notice.
Trust nothing back
Whatever a filter returns came from someone else's code. Validate the type before you use it, especially if the value ends up in a query, in SQL, or in output.
Example
<?php
function acme_place_order( array $items, int $user_id ) {
$order = array(
'items' => $items,
'user_id' => $user_id,
'total' => acme_calculate_total( $items ),
);
/**
* Filters an order immediately before it is stored.
*
* @since 1.2.0
*
* @param array $order Order data: items, user_id, total.
* @param int $user_id ID of the purchasing user.
*/
$order = apply_filters( 'acme_pre_insert_order', $order, $user_id );
// Never trust the return value of your own filter.
if ( ! is_array( $order ) || ! isset( $order['total'] ) ) {
return new WP_Error( 'acme_bad_order', __( 'Order filter returned invalid data.', 'acme' ) );
}
$order_id = acme_insert_order( $order );
/**
* Fires after an order has been stored.
*
* @since 1.2.0
*
* @param int $order_id ID of the new order.
* @param array $order Order data as stored.
*/
do_action( 'acme_order_placed', $order_id, $order );
return $order_id;
}When to use it
- Let site owners change your plugin's output without editing or forking it.
- Give an add-on plugin a place to run after your own save routine completes.
- Expose a settings default as a filter so it can be overridden in wp-config or a mu-plugin.
- Retire an old hook name without breaking sites that still use it.
More examples
The two equivalent firing forms
Recognise both when reading core: seeing do_action_ref_array() in a hook's source tells you the callback may be handed a reference and can modify the object directly, which is why pre_get_posts callbacks return nothing yet still change the query.
$order_id = 42;
$order = array( 'total' => 19.99 );
// These two are the same call. The second takes the arguments as an array.
do_action( 'acme_order_placed', $order_id, $order );
do_action_ref_array( 'acme_order_placed', array( $order_id, $order ) );
// Same pair for filters:
$order = apply_filters( 'acme_pre_insert_order', $order, $order_id );
$order = apply_filters_ref_array( 'acme_pre_insert_order', array( $order, $order_id ) );
// The _ref_array forms exist so you can pass by reference, which is how core lets
// callbacks mutate an object in place. WP_Query does exactly this:
// do_action_ref_array( 'pre_get_posts', array( &$this ) );
// They are also the practical form when the argument list is built dynamically:
$args = acme_build_context();
do_action_ref_array( 'acme_context_ready', $args );Making a default overridable
The pattern for every constant a user might reasonably want to change. The cast and the clamp matter: a filter is an open door, and hostile input is not the concern so much as an honest mistake in someone else's snippet reaching your storage layer.
function acme_get_cache_ttl() {
/**
* Filters how long Acme caches remote results, in seconds.
*
* @since 1.0.0
*
* @param int $ttl Time to live in seconds. Default 1 hour.
*/
$ttl = (int) apply_filters( 'acme_cache_ttl', HOUR_IN_SECONDS );
// Clamp what you get back rather than trusting it — a filter returning 0 or a
// negative number would make set_transient() store the value with no expiry.
return max( 60, min( $ttl, WEEK_IN_SECONDS ) );
}
// A site owner, in a mu-plugin:
add_filter( 'acme_cache_ttl', function ( $ttl ) {
return 15 * MINUTE_IN_SECONDS;
} );Retiring a hook without breaking sites
Use this whenever you rename or restructure a published hook. Sites keep working, and developers running WP_DEBUG see exactly what to change and when it happened. Removing the deprecated call outright is a breaking change and belongs in a major version, announced well in advance.
// Version 1.x fired this:
// do_action( 'acme_order_done', $order_id );
// Version 2.0 renames it to acme_order_placed and adds the order array.
do_action( 'acme_order_placed', $order_id, $order ); // the new hook
// Keep the old one alive, with a deprecation notice when WP_DEBUG is on.
// Signature: do_action_deprecated( $hook_name, $args, $version, $replacement, $message )
// Available since WordPress 4.6.0.
do_action_deprecated(
'acme_order_done',
array( $order_id ),
'2.0.0',
'acme_order_placed'
);
// Filters have the matching function, which still returns the filtered value:
$order = apply_filters_deprecated(
'acme_order_data',
array( $order ),
'2.0.0',
'acme_pre_insert_order',
'The order array now includes a total key.'
);
Discussion