WordPress Action Nesting: a cautionary tale

When you save a post in WordPress, the post’s data goes through wp_insert_post(), which in turn triggers the save_post action. So let’s say you have a callback hooking into save_post.

function my_first_callback( $post_id, $post ) {
  // do something...
}
add_action('save_post', 'my_first_callback', 10, 2);

So far, pretty simple. Now let’s put something in our callback function.

function my_first_callback( $post_id, $post ) {
  if ( $post->post_type == 'post' ) {
    wp_insert_post(array(
      'post_title' => 'A Post',
      'post_status' => 'publish',
      'post_type' => 'a_custom_post_type',
    ));
  }
}
add_action('save_post', 'my_first_callback', 10, 2);

Not a horribly complicated function. We’ll leave aside my reasons for creating a post in this callback (there’s a myriad of reasons why one might want to create or update a different post when a given post is saved) and focus on the consequences.

wp_insert_post(), called from my_first_callback() will once again trigger the save_post action, while we’re still in the middle of processing the first save_post action. So long as you avoid an infinite loop, there’s nothing necessarily wrong with that. But look closely inside do_action(), and you’ll find something else to worry about.

The $wp_filter array

WordPress stores all of your action (and filter) callbacks in the global $wp_filter array. This is a multidimensional array, with the hook names at the top level, the priority as the second level, and the callback at the third level. E.g., adding our callback above with add_action() is basically the same as:

$wp_filter['save_post'][10]['my_first_callback'] = array('function' => 'my_first_callback', 'accepted_args' => 2);

Inside do_action()

do_action('save_post') takes the $wp_filter['save_post'] array, sorts the priorities, then loops through each priority’s array of callback functions. Basically, a foreach loop nested inside of a foreach loop.

But the outer loop is not a PHP foreach loop. Instead, we manually progress through each priority using next(). Here’s the exact code in do_action():

do {
  foreach ( (array) current($wp_filter[$tag]) as $the_ )
    if ( !is_null($the_['function']) )
      call_user_func_array($the_['function'], array_slice($args, 0, (int) $the_['accepted_args']));
} while ( next($wp_filter[$tag]) !== false );

In many situations, this is functionally identical to a foreach loop, but in certain cases, it’s not.

Case 1: Adding callbacks while already looping through the hook

Let’s say that during your save_post callback function, you decide that you need to add another callback function with a later priority.

function my_first_callback( $post_id, $post ) {
  if ( $post->post_type == 'pistachio' ) {
    add_action('save_post', 'my_second_callback', 15, 2);
  }
}
function my_second_callback( $post_id, $post ) {
  // do something else
}
add_action('save_post', 'my_first_callback', 10, 2);

Due to the way do_action() is implemented, you can do this. my_second_callback() will be called at an appropriate(-ish) time (I’ll leave it as an exercise for the reader to figure out why it may not be in the exact order you might expect), because, by using next() instead of foreach, WordPress avoids making a copy of the $wp_filter['save_post'] array.

Case 2: Nested callbacks

Now lets go back to our original callback function, and give it a friend:

function my_first_callback( $post_id, $post ) {
  if ( $post->post_type == 'post' ) {
    wp_insert_post(array(
      'post_title' => 'A Post',
      'post_status' => 'publish',
      'post_type' => 'a_custom_post_type',
    ));
  }
}
function my_second_callback( $post_id, $post ) {
  // do something else
}
add_action('save_post', 'my_first_callback', 10, 2);
add_action('save_post', 'my_second_callback', 15, 2);

my_second_callback() will never be called if $post->post_type == 'post'. In fact, any callback with a later priority will not be called. How did that happen?

When my_first_callback() calls wp_insert_post(), we have, as mentioned above, some nesting going on with the save_post action. The inner call to do_action('save_post') cycles through the $wp_filter['save_post'] array until next() moves the pointer to the end of the array. After that loop is done, the outer call to do_action('save_post') doesn’t get its array pointer put back in the appropriate location. As far as it can tell, it’s looped through the entirety of the array and reached the end.

If do_action() used a foreach loop instead of the do-while construct with next(), it would make a copy of the $wp_filter['save_post'] array with its own pointer, and we wouldn’t have this problem, but then case 1 wouldn’t work. It looks like a conscious design decision, and I’m inclined to say the correct decision.

Making things right

So what do we do about that? Well, we can just avoid calling wp_insert_post() or wp_save_post() in our callbacks for the save_post hook. If you can avoid it, I highly recommend it. Sometimes, though, that’s not practical, so you have to take a different approach: put the pointer back.

If you know your callback will (or might) cause nesting of an action/filter, you need to figure out where the array pointer should be when you’re done, and put it there.

function my_first_callback( $post_id, $post ) {
  // track the current position of the array_pointer
  global $wp_filter;
  $wp_filter_index = key($wp_filter['save_post']);
  if ( $post->post_type == 'post' ) {
    wp_insert_post(array(
      'post_title' => 'A Post',
      'post_status' => 'publish',
      'post_type' => 'a_custom_post_type',
    ));
  }
  // put the pointer back
  reset($wp_filter['save_post']);
  foreach ( array_keys($wp_filter['save_post']) as $key ) {
    if ( $key == $wp_filter_index ) {
      break;
    }
    next($wp_filter['save_post']);
  }
}

At the moment, I’m happy to admit that this is not 100% robust. E.g., the array may have changed or been resorted since your function started. So you’re not guaranteed to return to the exact state you were in before your function was called. But you’re probably close enough, and much better off than if you hadn’t tried at all.

Update: Looks like there’s a WP trac ticket to change this. http://core.trac.wordpress.org/ticket/17817. It’s my opinion that we should leave it alone. Plugin authors should sort out their own nesting messes.

One thought on “WordPress Action Nesting: a cautionary tale”

  1. Ok – not the most common case i guess, but I’ve hit it while trying to copy posts to different blogs in multisite. Your explanation made me happy (as one can be after hunting such a subtle issue)!

Comments are closed.