Creating a Better WordPress Loop

A Better WordPress Loop

The WordPress loop is ubiquitous. It is one of the first things you learn as a WordPress developer and it is something you must know the intricacies of in order to avoid causing problems. I’ve dreamt of a day when the WordPress loop and all its various implementations are no longer necessary. Today, I would like to demonstrate how we can leverage a simple PHP generator to create a better WordPress loop.

Complexities of the WordPress Loop

At the moment, there are a few things that make the WordPress loop more complicated than it really has to be:

  1. The WordPress loop works differently if you are looping over the global query or have a separate instance of WP_Query. It is also very different if you just have an array of posts and want to loop through those.
  2. You have to remember to reset the post data after running secondary loops to avoid causing issues with the context of global variables after your loop.
  3. You have to remember to call the_post() function (or method) within each iteration of the loop.
  4. There is no the_post() function when looping through an array of posts, only setup_postdata() which means the global $post object is never set in that scenario unless you handle it yourself. As such, many functions that depend on the $post global simply don’t work (e.g. the_title(), the_excerpt(), etc.).

It doesn’t have to be this way. We can solve these issues and have a better WordPress loop.

The Beauty of a Generator

This is the background behind my code and why a generator is an elegant approach to creating a better WordPress loop. Don’t care? Skip to the results.

Getting Started

A generator is, first and foremost, a function. So let’s start by giving it a name and setting up the parameters.

/**
 * Simplifies the WordPress loop.
 * 
 * @param WP_Query|WP_Post[] $iterable
 */
function wp_loop( $iterable = null ) {

}

We’ll call our function wp_loop() since that is what it handles. As you can see, it allows an iterable to be passed as a parameter. The default is that no iterable is required. What is an iterable? Essentially it is an array or anything that implements the Traversable interface. In our case, it will either be a WP_Query instance or an array of WP_Post objects. While WP_Query doesn’t technically implement the Traversable interface, we’re just going to fetch the array of posts from it… so for all intents and purposes, it is an iterable.

In the event that an iterable isn’t provided, we want to default to using the global WP_Query object:

/**
 * Simplifies the WordPress loop.
 *
 * @param WP_Query|WP_Post[] $iterable
 */
function wp_loop( $iterable = null ) {

   if ( null === $iterable ) {
      $iterable = $GLOBALS['wp_query'];
   }

}

Lowest Common Denominator

In order for our function to loop through an array of posts just the same as it would a WP_Query instance, we’ll use a “lowest common denominator” version of the loop… the approach you would use to loop through an array of posts.

This is the traditional way to loop through an array of posts:

foreach ( $posts as $post ) {
   setup_postdata( $post );
   // Do stuff here
}
wp_reset_postdata();

The only problem with this approach is that the global $post object isn’t properly set as it would be if you looped through a WP_Query instance. To resolve this, we’d have to handle setting the global post object ourselves:

global $post;

// Save the global post object so we can restore it later
$save_post = $post;

foreach ( $posts as $post ) {
   setup_postdata( $post );
   // Do stuff here
}
wp_reset_postdata();

// Restore the global post object
$post = $save_post;

Data Validation

OK, so we have a plan. Before we add this code to our function, let’s make sure our function always has an array of posts:

$posts = $iterable;
if ( is_object( $iterable ) && property_exists( $iterable, 'posts' ) ) {
   $posts = $iterable->posts;
}

We are assuming our iterable is an array of posts and setting the $posts variable to be equal to the iterator. Otherwise, if the iterator is a query, it will have a posts property we can fetch the posts from. I intentionally don’t check to see if the iterable is an actual WP_Query object so that the function becomes more flexible. It can take any object where there is a posts property.

What happens if someone passes in an invalid value to the function? Good point… let’s add some error checking.

if ( ! is_array( $posts ) ) {
   throw new \InvalidArgumentException( sprintf( 'Expected an array, received %s instead', gettype( $iterable ) ) );
}

Great! Now our function will throw an exception if it receives an invalid value.

Generator Magic

Let’s take a look at what our code looks like now if we put those pieces together:

/**
 * Simplifies the WordPress loop.
 *
 * @param WP_Query|WP_Post[] $iterable
 *
 * @return Generator
 */
function wp_loop( $iterable = null ) {

   if ( null === $iterable ) {
      $iterable = $GLOBALS['wp_query'];
   }

   $posts = $iterable;
   if ( is_object( $iterable ) && property_exists( $iterable, 'posts' ) ) {
      $posts = $iterable->posts;
   }

   if ( ! is_array( $posts ) ) {
      throw new \InvalidArgumentException( sprintf( 'Expected an array, received %s instead', gettype( $posts ) ) );
   }

   global $post;

   // Save the global post object so we can restore it later
   $save_post = $post;

   foreach ( $posts as $post ) {
      setup_postdata( $post );
      // Do stuff here
   }
   wp_reset_postdata();

   // Restore the global post object
   $post = $save_post;

}

We’re getting there, but that pesky // Do stuff here bit is always going to be different. Let’s make one small change. We’ll remove the // Do stuff here comment and replace it with yield $post;. This will turn our function into a generator and allow it to return the post. This allows us to use the function like this:

foreach ( wp_loop() as $post ) {
   echo '<h1>' . esc_html( get_the_title() ) . '</h1>';
}

This will loop through all the posts from the global WP_Query and output the title. This should give you a basic idea of how simple it can make the loop.

Finally

The only problem here is that the wp_reset_postdata() part of our code doesn’t run if this is a generator because the yield acts as a return and after the last post is returned the rest of the code isn’t run. However, all we need to do to fix this is to utilize a tryfinally statement, like this:

global $post;

// Save the global post object so we can restore it later
$save_post = $post;

try {

   foreach ( $posts as $post ) {
      setup_postdata( $post );
      // Do stuff here
   }
   
} finally {

   wp_reset_postdata();

   // Restore the global post object
   $post = $save_post;    
   
}

Now, anytime the generator “wraps up”, the code in the finally block will be run. This covers not only the standard use case where we are looping through all our posts, but also if we decide to break out of our loop early, like this:

foreach ( wp_loop() as $post ) {
   echo '<h1>' . esc_html( get_the_title() ) . '</h1>';
   break;
}

Super awesome!

Now the global $post object is properly set, along with a number of other important globals that setup_postdata() handles. We also don’t have to remember to reset the query when we are done, that just happens automatically! Essentially, our generator takes all the things we’d normally have to remember to do and does them for us!

Another beautiful thing about this approach is that if you mess with the $post variable in this loop, it isn’t actually the global object, so you can’t mess up global context at all!

A Better WordPress Loop

A generator allows us to extract all the things we would normally have to remember to do in the WordPress loop and put them in a function that handles them automatically… a better WordPress loop indeed!

Here is the final code (works in PHP 5.6+):

/**
 * Simplifies the WordPress loop.
 *
 * @param WP_Query|WP_Post[] $iterable
 *
 * @return Generator
 */
function wp_loop( $iterable = null ) {

   if ( null === $iterable ) {
      $iterable = $GLOBALS['wp_query'];
   }

   $posts = $iterable;
   if ( is_object( $iterable ) && property_exists( $iterable, 'posts' ) ) {
      $posts = $iterable->posts;
   }

   if ( ! is_array( $posts ) ) {
      throw new \InvalidArgumentException( sprintf( 'Expected an array, received %s instead', gettype( $posts ) ) );
   }

   global $post;

   // Save the global post object so we can restore it later
   $save_post = $post;

   try {

      foreach ( $posts as $post ) {
         setup_postdata( $post );
         yield $post;
      }

   } finally {

      wp_reset_postdata();

      // Restore the global post object
      $post = $save_post;

   }

}

Here is how you can use the generator for the global loop:

foreach ( wp_loop() as $post ) {
   echo '<h1>' . esc_html( get_the_title() ) . '</h1>';
}

Alternatively, you can also directly pass in a WP_Query object, like this:

foreach ( wp_loop( new WP_Query( 'post_type=post' ) ) as $post ) {
   echo '<h1>' . esc_html( get_the_title() ) . '</h1>';
}

Or, you can just throw some posts at it:

foreach( wp_loop( get_posts() ) as $post ) {
   echo '<h1>' . esc_html( get_the_title() ) . '</h1>';
}

You can always add additional checks if you need to:

if ( have_posts() ) {
   foreach ( wp_loop() as $post ) {
      echo '<h1>' . esc_html( get_the_title() ) . '</h1>';
   }
} else {
   echo '<h1>No posts found!</h1>';
}

Try it out on your own projects and let me know what you think!

Do you think this should be added to core?