Sorting posts according to the term they belong to

I’m not going to discuss implementation of a slider, that is too broad. There are a couple of tutorials around which you can look at on how to implement simple sliders

The real issue here is the query itself. Sorting a query by the terms a post belongs to is quite heavy operations, and if not done correctly (no offense, as you have done in your answer), it can be really really expensive. The overly-used method of getting all terms and then looping through them and running a query for each term can have you stuck at an operation making 100 db calls, and that is only on a small site with only a few terms and posts. Very large sites can pack the meat on db calls which can see your SEO rating stuck at minus and page load times deep in the red.

I have done a few posts on this specific subject, so I actually wanted to mark this as duplicate, but I feel that this needs a fresh look with a different approach.

What I have done in the passed is to do everything (query all the posts, then sort the result and displaying them) in one single operation. This complete query result was then stored in a transient. The setback here is that we store a tremendous amount of serialized data in the transient which again is not really what transients should do.

What I will do here is to build a dynamic sorting script which will do all the hard work and will store only post ID’s in a transient (this will take care of not having to store huge amounts of serialized data which can be slow). We will then use the sorted post ID’s from the transient to get our posts that we will need. Because posts are saved in a cache, we do not hit the db hard to get our desired posts, we simply query and return the posts from the cache if they are already there

FEW IMPORTANT NOTES:

  • Although I have tested the code, it is not fully tested. On my install there are no obvious bugs at this stage. You should however test this fully with debug turned on on a local test install before moving this to production

  • For what it is worth, the code need at least PHP5.4, but you are already on at least PHP5.6, or even better, PHP7 😉

  • There are a few orderby parameters that is not excepted by the code. You can extend it if needed. Check the post search I have linked to. I have done a lot of different stuff with usort()

  • The code uses the first term returned by get_the_terms, so posts with multiple terms might be sorted incorrectly due to the first term being used

THE SORTING SCRIPTS:

What we will do is, we will get all the posts according to our needs, but we will only return the post object properties we need to save resources. We will then use usort() to sort the posts according to terms. Instead of creating something static that you will need to alter when needed, we will make this dynamic so you can reuse it as much as you like by only changing the values of the parameters passed

/**
 * Function to return an ordered list of post id's according to the terms they belong to.
 *
 * This function accepts four parameters
 * @param (string) $taxonomy The taxonomy to get posts and terms from
 * @param (array) $args An array of arguments valid in 'WP_Query'
 * @param (array) $term_args An array of arguments valid with 'get_terms'
 * @param (array) $sort An array of parameters used to sort posts in 'usort()'
 * @return $ids
 */
function get_sorted_query_results( 
    $taxonomy = 'category', 
    $args = [], 
    $term_args = [], 
    $sort = [] 
) {
    /** 
     * To avoid unexpected ordering, we will avoid any odering that is not done by a WP_Post
     * property. If we incounter any ordering outside that, we would simply return false and
     * stop execution
     */
    $avoid_this_ordering = [
        'none', 
        'type', 
        'rand', 
        'comment_count', 
        'meta_value', 
        'meta_value_num', 
        'post__in'
    ];
    if ( isset( $args['orderby'] ) ) {
        if ( in_array( $args['orderby'], $avoid_this_ordering ) )
            return null;
    }

    // Validate and sanitize the taxonomy name
    if (    'category' !== $taxonomy
         && 'post_tag' !== $taxonomy
    ) {
        $taxonomy = filter_var( $taxonomy, FILTER_SANITIZE_STRING );
        if ( !taxonomy_exists( $taxonomy ) )
           return null;
    }

    // Now that we have run important test, set the transient

    // Set a transient to store the post ID's. Excellent for performance
    $transient_name="pobt_" . md5( $taxonomy . json_encode( array_merge( $args, $term_args, $sort ) ) );
    if ( false === ( $ids = get_transient ( $transient_name ) ) ) {

        // Set up a variable to hold an array of post id and id that should not be duplicated       
        // Set our query defaults
        $defaults = [
            'posts_per_page' => -1,
            'order'          => 'DESC',
            'orderby'        => 'date'
        ];
        /**
         * If tax_query isn't explicitely set, we will set a default tax_query to ensure we only get posts
         * from the specific taxonomy. Avoid adding and using category and tag parameters
         */
        if ( !isset( $args['tax_query'] ) ) {   
            // Add 'fields'=>'ids' to $term_args to get only term ids
            $term_args['fields'] = 'ids';
            $terms = get_terms( $taxonomy, $term_args );
            if ( $terms ) { // No need to check for WP_Error because we already now the taxonomy exist
                $defaults['tax_query'] = [
                    [
                        'taxonomy' => $taxonomy,
                        'terms'    => $terms
                    ]
                ];
            } else {
                // To avoid unexpected output, return null
                return null;
            }
        }
        // Merge the defaults wih the incoming $args
        $query_args = $defaults;
        if ( $args )
            $query_args = wp_parse_args( $args, $defaults );

        // Make sure that 'fields' is always set to 'all' and cannot be overridden
        $query_args['fields'] = 'all';
        // Always allow filters to modify get_posts()
        $query_args['suppress_filters'] = false;

        /**
         * Create two separate arrays:
         * - one to hold numeric values like dates and ID's 
         * one with lettering strings like names and slugs. 
         * 
         * This will ensure very reliable sorting and also we
         * will use this to get only specific post fields
         */
        $orderby_num_array = [
            'date'       => 'post_date', 
            'modified'   => 'post_modified', 
            'ID'         => 'ID', 
            'menu_order' => 'menu_order',
            'parent'     => 'post_parent',
            'author'     => 'post_author'
        ];
        $orderby_letter_array = [
            'title'      => 'post_title',
            'name'       => 'post_name',
        ];
        // Merge the two arrays to use the combine array in our filter_has_var
        $orderby_comb_array = array_merge( $orderby_num_array, $orderby_letter_array );

        //Now we will filter get_posts to just return the post fields we need
        add_filter( 'posts_fields', function ( $fields ) 
            use ( 
                $query_args, 
                $orderby_comb_array 
            )
        {
            global $wpdb;

            remove_filter( current_filter(), __FUNCTION__ );

            // If $query_args['orderby'] is ID, just get ids
            if ( 'ID' === $query_args['orderby'] ) { 
                $fields = "$wpdb->posts.ID";
            } else { 
                $extra_field = $orderby_comb_array[$query_args['orderby']];
                $fields = "$wpdb->posts.ID, $wpdb->posts.$extra_field";
            }

            return $fields;
        });

        // Now we can query our desired posts
        $q = get_posts( $query_args );

        if ( !$q ) 
            return null;

        /**
         * We must now set defaults to sort by. 'order' will the order in which terms should be sorted
         * 'orderby' by defualt will be the value used to sort posts by in the query. This will be used
         * when a post has the same term as another post, then posts should be sorted by this value
         */
        $sort_defaults = [
            'order'   => 'ASC', // This will sort terms from a-z
            'orderby' => $query_args['orderby'] // Use the default query order
        ];

        // Merge the defaults and incoming args
        $sorting_args = $sort_defaults;
        if ( $sort )
            $sorting_args = wp_parse_args( $sort, $sort_defaults );

        /**
         * Now we can loop through the posts and sort them with usort()
         *
         * There is a bug in usort causing the following error:
         * usort(): Array was modified by the user comparison function
         * @see https://bugs.php.net/bug.php?id=50688
         * The only workaround is to suppress the error reporting
         * by using the @ sign before usort in versions before PHP7
         *
         * This bug have been fixed in PHP 7, so you can remove @ in you're on PHP 7
         */
        @usort( $q, function ( $a, $b ) 
            use ( 
                $taxonomy, 
                $sorting_args, 
                $orderby_num_array, 
                $orderby_letter_array 
            )
        {   

            /**
             * Get the respective terms from the posts. We will use the first
             * term's name. We can safely dereference the array as we already
             * made sure that we get posts that has terms from the selected taxonomy
             */
            $post_terms_a = get_the_terms( $a, $taxonomy )[0]->name;
            $post_terms_b = get_the_terms( $b, $taxonomy )[0]->name;

            // First sort by terms
            if ( $post_terms_a !== $post_terms_b ) {
                if ( 'ASC' === $sorting_args['order'] ) {
                    return strcasecmp( $post_terms_a, $post_terms_b );
                } else { 
                    return strcasecmp( $post_terms_b, $post_terms_a );
                }
            }

            /**
             * If we reached this point, terms are the same, we need to sort the posts by 
             * $query_args['orderby']
             */
            if ( in_array( $sorting_args['orderby'], $orderby_num_array ) ) {
                if ( 'ASC' === $sorting_args['order'] ) {
                    return $a->$orderby_num_array[$sorting_args['orderby']] < $b->$orderby_num_array[$sorting_args['orderby']];
                } else { 
                    return $a->$orderby_num_array[$sorting_args['orderby']] > $b->$orderby_num_array[$sorting_args['orderby']];
                }
            } elseif ( in_array( $sorting_args['orderby'], $orderby_letter_array ) ) { 
                if ( 'ASC' === $sorting_args['order'] ) {
                    return strcasecmp( 
                        $a->$orderby_num_array[$sorting_args['orderby']], 
                        $b->$orderby_num_array[$sorting_args['orderby']] 
                    );
                } else { 
                    return strcasecmp( 
                        $b->$orderby_num_array[$sorting_args['orderby']], 
                        $a->$orderby_num_array[$sorting_args['orderby']] 
                    );
                }
            }
        });

        // Get all the post id's from the posts
        $ids = wp_list_pluck( $q, 'ID' );

        set_transient( $transient_name, $ids, 30*DAY_IN_SECONDS );  
    }
    return $ids;
}

I have commented the code to make it easy to follow. A few notes on the parameters though

  • $taxonomy. This is the taxonomy to get terms and posts from

  • $args. An array of any valid argument that is acceptable in WP_Query. There are a few defaults set,

    $defaults = [
        'posts_per_page' => -1,
        'order'          => 'DESC',
        'orderby'        => 'date'
    ];
    

    so you can use this parameter to set any extras like ‘post_type’ and a tax_query if you need more specific posts

  • $term_args. An array of parameters valid with get_terms. This is used to get more spefic terms from $taxonomy.

  • $sort An array of arguments used to determine the sort order in usort(). Valid arguments are order and orderby

Remember, if you do not need to set a parameter and you need to set one adjacent, pass an empty array, except for $taxonomy which must be set to a valid taxonomy

EXAMPLE

$taxonomy = 'my_taxonomy';
$args = [
    'post_type' => 'my_post_type'
];
$term_args = [];
$sort = [
    'order' => 'DESC'
];
$q = get_sorted_query_results( $taxonomy, $args, $term_args, $sort );

This, on my install runs 3 db queries in +/- 0.002 seconds.

We have save all the relevant sorted post ID’s in a transient which expires every 30 days. We need to flush it as well when we publish a new post, update, delete or undelete a post. Very generically, you can make use of the transition_post_status hook to flush the transient on the above cases

add_action( 'transition_post_status', function ()
{
    global $wpdb;
    $wpdb->query( "DELETE FROM $wpdb->options WHERE `option_name` LIKE ('_transient%_pobt_%')" );
    $wpdb->query( "DELETE FROM $wpdb->options WHERE `option_name` LIKE ('_transient_timeout%_pobt_%')" );
});

There are quite a lot of examples on site with specific uses to target specific post status changes. You also have the post object available, so you can target specific post types, authors, etc

Now that you have an array of sorted id’s you can now query complete posts. Here we would use the post__in parameter and as orderby value to return the desired posts already sorted

From the above example, we would do

if ( $q ) { // Very important check to avoid undesired results if $q is empty
    $query_args = [
        'post__in' => $q,
        'order'    => 'ASC',
        'orderby'  => 'post__in',
        // Any other parameters
    ];
    $query = new WP_Query( $query_args );
    // Run your loop, see code below
}

All we need to do now is to display the terms names as headings. This is quite simple, compare the current post term with the previous post’s term, if they do not match, display term name, otherwise, display nothing

if ( $query->have_posts() ) {
    // Define variable to hold previous post term name
    $term_string = '';
    while ( $query->have_posts() ) {
    $query->the_post();
        // Get the post terms. Use the first term's name
        $term_name = get_the_terms( get_the_ID(), 'TAXONOMY_NAME_HERE' )[0]->name;
        // Display the taxonomy name if previous and current post term name don't match
        if ( $term_string != $term_name )
            echo '<h2>' . $term_name . '</h2>'; // Add styling and tags to suite your needs

        // Update the $term_string variable
        $term_string = $term_name;

        // REST OF YOUR LOOP

    }
    wp_reset_postdata();
}

Here is the complete query as I have run it on my install

$taxonomy = 'category';
$args = [
    'post_type' => 'post'
];
$term_args = [];
$sort = [
    'order' => 'DESC'
];
$q = get_sorted_query_results( $taxonomy, $args, $term_args, $sort );

if ( $q ) { // Very important check to avoid undesired results if $q is empty
    $query_args = [
        'posts_per_page' => count( $q ),
        'post__in'       => $q,
        'order'          => 'ASC',
        'orderby'        => 'post__in',
        // Any other parameters
    ];
    $query = new WP_Query( $query_args );

    if ( $query->have_posts() ) {
        // Define variable to hold previous post term name
        $term_string = '';
        while ( $query->have_posts() ) {
        $query->the_post();
            // Get the post terms. Use the first term's name
            $term_name = get_the_terms( get_the_ID(), 'category' )[0]->name;
            // Display the taxonomy name if previous and current post term name don't match
            if ( $term_string != $term_name )
                echo '<h2>' . $term_name . '</h2>'; // Add styling and tags to suite your needs

            // Update the $term_string variable
            $term_string = $term_name;

            // REST OF YOUR LOOP
            the_title();

        }
        wp_reset_postdata();
    }
}

On test with 21 posts and 6 categories, the whole operation cost me 7 queries in 0.1 seconds

With the overly-used easy method

$terms= get_terms( 'category' );
foreach ( $terms as $term ) {
    echo $term->name;
    $args = [
        'tax_query' => [
            [
                'taxonomy' => 'category',
                'terms' => $term->term_id,
            ]
        ],
    ];
    $query = new WP_Query( $args );
    while ( $query->have_posts() ) {
        $query->the_post();

        the_title();
    }
    wp_reset_postdata();
}

I got 32 queries in 0.2 seconds. That is 25 queries (5times more) more, and that is only on 6 categories and 21 posts.