One-time callbacks with the WordPress Plugin API

The WordPress Plugin API is a fantastic way for third-party scripts to be able to inject themselves into the WordPress lifecycle; want to change the output of the post content? Simply attach a callback to the the_content filter:

/**
 * Inject a cat emoji at the end of the post content.
 *
 * @param string $content The post content.
 * @return string The filtered post content.
 */
function inject_cat_emoji( $content ) {
  return $content .= ' ?';
}
add_filter( 'the_content', 'inject_cat_emoji' );

Now, whenever content is run through the the_content filter, our “?” will be appended.

Limiting the scope of actions and filters

Using actions and filters (collectively, “hooks”) within WordPress can have some unintended consequences, however; in our example from above, everything that runs through the_content will be given a cat; what if we only want it in certain places or under certain conditions?

The obvious solution is to build more checks into the callback function itself, perhaps checking for certain conditions to be true.

function inject_cat_emoji( $content ) {

  // Only inject an emoji if "cat" is in the content.
  if ( false !== strpos( $content, 'cat' ) ) {
    $content .= ' ?';
  }

  return $content;
}

Oftentimes, we’ll be using additional arguments that have been passed to the function or global conditional functions (e.g. is_single(), is_home(), etc.) to help give us extra context.

In more complex situations, it’s not uncommon to need to add and subsequently remove an action or filter if it’s only meant to be executed once:

/*
 * Only run the content through inject_cat_emoji() if its
 * about cats.
 */
if ( has_tag( 'cats' ) ) {
  add_filter( 'the_content', 'inject_cat_emoji' );
  the_content();
  remove_filter( 'the_content', 'inject_cat_emoji' );
}

Since the_content() automatically runs the post content through the the_content filter, this pattern will inject our inject_cat_emoji() callback just long enough for it to be applied to this particular post, then it’s removed.

This pattern works, but it’s very easy to make a mistake in its implementation, which can lead to stray cats all over your post content.

Enter one-time callbacks

Instead, we can add two new functions to WordPress: add_action_once() and add_filter_once(); these functions register our actions and filter callbacks, respectively, but also automatically deregister the callbacks after they’ve been executed once.

<?php
/**
 * Registers the "One time hook" functionality.
 *
 * Note that this file is intentionally in the *global* 
 * namespace!
 *
 * @author  Growella
 * @license MIT
 */

if ( ! function_exists( 'add_action_once' ) ) {
  /**
   * Register an action to run exactly one time.
   *
   * The arguments match that of add_action(), but this
   * function will also register a second callback designed
   * to remove the first immediately after it runs.
   *
   * @param string   $hook     The action name.
   * @param callable $callback The callback function.
   * @param int      $priority Optional. The priority at
   *                           which the callback should be
   *                           executed. Default is 10.
   * @param int      $args     Optional. The number of
   *                           arguments expected by the
   *                           callback function.
   *                           Default is 1.
   * @return bool Like add_action(), this function always
   *              returns true.
   */
  function add_action_once( $hook, $callback, $priority = 10, $args = 1 ) {
    $singular = function () use ( $hook, $callback, $priority, $args, &$singular ) {
      call_user_func_array( $callback, func_get_args() );
      remove_action( $hook, $singular, $priority, $args );
    };

    return add_action( $hook, $singular, $priority, $args );
  }
}

if ( ! function_exists( 'add_filter_once' ) ) {
 /**
 * Register a filter to run exactly one time.
 *
   * The arguments match that of add_filter(), but this
   * function will also register a second callback designed
   * to remove the first immediately after it runs.
   *
   * @param string   $hook     The action name.
   * @param callable $callback The callback function.
   * @param int      $priority Optional. The priority at
   *                           which the callback should be
   *                           executed. Default is 10.
   * @param int      $args     Optional. The number of
   *                           arguments expected by the
   *                           callback function.
   *                           Default is 1.
   * @return bool Like add_filter(), this function always
   *              returns true.
   */
  function add_filter_once( $hook, $callback, $priority = 10, $args = 1 ) {
    $singular = function () use ( $hook, $callback, $priority, $args, &$singular ) {
      call_user_func_array( $callback, func_get_args() );
      remove_filter( $hook, $singular, $priority, $args );
    };

    return add_filter( $hook, $singular, $priority, $args );
  }
}

Grab a copy on GitHub

The code works by registering a new, anonymous function (closure) as the callback, which both executes the original callback function as well as removes itself from the filter queue (so it can’t be called again). The trick here is assigning our closure to a variable, then enabling our closure to access itself by reference via the use keyword (StackOverflow thread for reference).

When implementing this code in your theme, simply replace calls to add_action() or add_filter() that should only be executed once with add_action_once() or add_filter_once(), respectively. The function signatures are exactly the same, and the one-time callbacks will clean up after themselves automatically.

if ( has_tag( 'cats' ) ) {
  add_filter_once( 'the_content', 'inject_cat_emoji' );
}

the_content();

When we have time, we’d love to release this as a Composer library for WordPress, but until then please feel free to use this under the MIT License. Additionally, a special thanks goes to John Bloch for code review and discussions.

Leave a Reply