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.

Syntaxdo_action( string $hook_name, mixed ...$arg ): void | apply_filters( string $hook_name, mixed $value, mixed ...$args ): mixed

You 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

Example · php
<?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.

Example · php
$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.

Example · php
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.

Example · php
// 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

  • Be the first to comment on this lesson.