Related Posts in Wordpress Without Plugins

Adding a list of related posts to web pages is a great way to help users find new content and keep visitors engaged on your website. I was recently adding this feature to a website built on WordPress. There are several WordPress plugins that implement related posts functionality, but I wanted precise control over the function and styling of the related posts. I also didn’t want the overhead and hassle of installing a new plugin, so I implemented the related posts feature myself.

I drew heavily from this article on the WordPress Stack Exchange. It’s pretty old, but the answers still mostly worked. I combined features from all three answers to build a working implementation.

Requirements

The site in question uses WordPress’s default taxonomiescategories and tags—to organize its content. The best indication that content is related is that it’s in the same category. There are nested categories, and posts can belong to multiple categories. So the content most likely to be relevant is the content with the most shared categories.

If there are lots of posts in the same category, we need a way to identify the posts in the category that are most likely to be related.

However, some categories have lots of posts, while others only have a handful. In some cases, we won’t have enough content within the same category to provide related recommendations. In those cases, I wanted to reach beyond the category to identify related content. To do that, we can analyze the tags. As with categories, the number of tags in common between two posts is going to be the best indicator of relevance.

The Code

Here’s the code I came up with:

function get_related_posts_by_taxonomies( 
  $post_id,
  $number_posts = 4,
  $post_type = 'post',
  $taxonomy = 'category',
  $second_taxonomy = 'post_tag',
) {
  // Specify a transient to store related posts
  $transient_name = 'related-' . $taxonomy . '-' . $post_id;

  // Allow logged-in users to force a refresh of related content
  if( isset($_GET['flush-related-links']) && is_user_logged_in() ) {
    echo '<p>Related links flushed! (' . $transient_name . ')</p>';
    delete_transient( $transient_name );
  }

  // If we have saved related content, just use that
  $output = get_transient( $transient_name );
  if( $output !== false && !is_preview() ) {
    return $output;
  }

  global $wpdb;

  $post_id = (int) $post_id;
  $number_posts = (int) $number_posts;

  $limit = $number_posts > 0 ? ' LIMIT ' . $number_posts : '';
   
  // Query the database for posts with shared categories
  $related_posts_records = $wpdb->get_results(
    $wpdb->prepare(
      "SELECT tr.object_id, count( tr.term_taxonomy_id ) AS common_tax_count
        FROM {$wpdb->term_relationships} AS tr
        INNER JOIN {$wpdb->term_relationships} AS tr2 ON tr.term_taxonomy_id = tr2.term_taxonomy_id
        INNER JOIN {$wpdb->term_taxonomy} as tt ON tt.term_taxonomy_id = tr2.term_taxonomy_id
        INNER JOIN {$wpdb->posts} as p ON p.ID = tr.object_id
        WHERE
          tr2.object_id = %d
          AND tt.taxonomy = %s
          AND p.post_type = '$post_type'
          AND p.post_status = 'publish'
        GROUP BY tr.object_id
        HAVING tr.object_id != %d
        ORDER BY common_tax_count DESC LIMIT 25" ,
      $post_id, $taxonomy, $post_id
    )
  );

  // If there aren't enough posts in the same category, look for posts with shared tags
  if( count($related_posts_records) < $number_posts ) {
    $more_posts = $wpdb->get_results(
      $wpdb->prepare(
        "SELECT tr.object_id, count( tr.term_taxonomy_id ) AS common_tax_count
          FROM {$wpdb->term_relationships} AS tr
          INNER JOIN {$wpdb->term_relationships} AS tr2 ON tr.term_taxonomy_id = tr2.term_taxonomy_id
          INNER JOIN {$wpdb->term_taxonomy} as tt ON tt.term_taxonomy_id = tr2.term_taxonomy_id
          INNER JOIN {$wpdb->posts} as p ON p.ID = tr.object_id
          WHERE
            tr2.object_id = %d
            AND tt.taxonomy = %s
            AND p.post_type = '$post_type'
            AND p.post_status = 'publish'
          GROUP BY tr.object_id
          HAVING tr.object_id != %d
          ORDER BY common_tax_count DESC LIMIT 25",
        $post_id, $second_taxonomy, $post_id
      )
    );

    $related_posts_records = $related_posts_records + $more_posts;
  } else if( count($related_posts_records) > $number_posts ) {
    // If there are more posts than we need, identify the number of tags in common
    $tags = wp_get_post_tags($post_id);
    if ($tags) {
      $tag_ids = array();
      foreach($tags as $individual_tag) $tag_ids[] = $individual_tag->term_id;
      foreach( $related_posts_records as $p ) {
        $cmp_tags = wp_get_post_tags( intval($p->object_id) );
        $cmp_tag_ids = array();
        foreach( $cmp_tags as $cmp_tag) $cmp_tag_ids[] = $cmp_tag->term_id;
        $p->common_tax2_count = count(array_intersect($tag_ids, $cmp_tag_ids));
      }
      // Then sort the results by the number of common tags
      usort( $related_posts_records, "comparePostsByCommonTax" );
    }
  }

  if ( count( $related_posts_records ) === 0 )
    return false;

  $related_posts = array();

  $count = 0;
  foreach( $related_posts_records as $record ) {
    if( $count >= $number_posts ) break;
    $related_posts[] = array(
      'post_id' => (int) $record->object_id,
      'common_tax_count' => $record->common_tax_count
    );
    $count ++;
  }

  // Store the results in a transient so we dont' regenerate the related content every time
  if( !is_preview() ) {
    set_transient( $transient_name, $related_posts, 24 * HOUR_IN_SECONDS );
  }
  return $related_posts;
}

// Helper function to sort the results
function comparePostsByCommonTax( $a, $b ) {
  $common = $b->common_tax_count - $a->common_tax_count;
  if( $common !== 0 )
    return $common;
  return $b->common_tax2_count - $a->common_tax2_count;
}

Breaking it Down

Here’s a brief overview of what the code does.

Parameters

The main function is get_related_posts_by_taxonomies, and it accepts five parameters:

  1. $post_id: the ID of the post for which you want to find related content. This is the only required parameter.
  2. $number_posts: the maximum number of related posts to return (default is 4)
  3. $post_type : the type of post from which to pull related content (default is post)
  4. $taxonomy: the first taxonomy to use to identify related content (default is category)
  5. $second_taxonomy: the second taxonomy to use to identify related content (default is post_tag)

Transient storage

It begins by creating a transient, temporary stored data. Generating the related posts isn’t hugely resource-intensive, but it does require a couple of database calls. So, to speed things up, we cache the related posts in a transient for a set amount of time (the default is 24 hours). If there are related posts for post_id stored in a transient, we just return the stored results rather than generate new ones.

Querying by first taxonomy

If there are not cached related posts, we have to generate them. We start by building a SQL query of the WordPress database that searches for posts of the specified type (excluding the specified post itself) that share at least one of the first taxonomy with the specified post ordered by the number of categories they share.

Too many or too few posts

If there aren’t enough posts with shared categories, we build a similar query that searches for posts that share at least one of the second taxonomy with the specified post, sorted by the number of common tags. Then we append the results of this second query to the results of the first query.

If there are more posts than we need, we count the number of shared tags for each related post, and we sort the results from most common tags to fewest.

Returning results

If there are no related posts at all, we return the boolean false;

Otherwise, we generate an array of the results. Each result is a PHP object containing the ID of the related post and the number of the first taxonomies the posts have in common.

Using the Code

In the description above, I used post as the post_type, category as the first taxonomy, and tag as the second taxonomy. Those are the defaults, but everything is customizable; you can use a different post type or different taxonomies if you like.

Using the code is pretty simple. I had already built a plugin to generate the custom post types for the website, so I just added the code above to that plugin. If you’re not using a plugin, you could just add the code to your theme’s functions.php file.

Then you need to add some code where you want the related posts to appear. For example, you could add it to the single.php or template-parts/content.php file in your theme. Here’s an example of what that might look like:

<?php
  $related = get_related_posts_by_taxonomies( $post->ID );
  if( $related && count($related) > 0): ?>
<h2>You might also be interested in</h2>
<ol class="related-content">
<?php
  foreach( $related as $p ):
    $post_obj = get_post($p['post_id']);
?>
  <li><a href="<?php the_permalink($post_obj); ?>"><?php echo get_the_title($post_obj); ?></a></li>
<?php
  endforeach; 
  endif;
?>
</ol>