Raw Solution to Ordering an Array of Page Objects Hierarchically

Sometimes the need arises for an array of Page objects ordered hierarchically, but all there is available is just a collection of Page objects ordered by title for example, maybe because they were previously fetched from the database through the global $wpdb object. On these occasions, to hierarchically reorder an array as such, we have to be creative, because WordPress isn't equipped with an utility function made up on purpose — or at least I have not dug enough to uncover one. In truth, I simply enjoyed looking for my own solution, and that's the main reason why I am here now writing about it.

In a nutshell, I achieved my goal by approaching to the problem in two steps: the first consists in creating a temporary array where the elements are arrays of Page objects sharing the same value of post_parent (child pages), the second step boils down to combining the temporary array with an array of top-level Page objects (Page objects having post_parent = 0) also born inside the same loop that populated the temporary array.

Do not panic, I will go through each step in detail.

The Master Array

The code subject of this writing originated from the development cycle of SiteTree 5.3, while I was refactoring a block of code intended to produce a list of <option> tags for a drop-down control (<select> tag). The <option> tags had their visible part indented so as to look like a hierarchical list, that actually represented part of the Pages available on the website.

My intention now is just to give a context and a source to our array of Page objects.

Aforesaid list of <option> tags had been the result of the manipulation of an array of Page objects offspring of a $wpdb->get_results() call very similar to this:

global $wpdb;

// It is important that the objects resulting from the query
// have at least an 'ID' and a 'post_parent' attribute.
$the_query = "SELECT ID, post_title, post_parent
              FROM {$wpdb->posts}
              WHERE post_type = 'page' AND post_status = 'publish' AND
                    post_password = '' 
              ORDER BY menu_order, post_title ASC";

// The array of Page objects to order hierarchically.
$pages = $wpdb->get_results( $the_query );

I want to emphasise that for the code I am going to describe you to work, the Page objects in the array to reorder must have at least an ID and a post_parent attribute, regardless of whether such objects are instances of the WP_Post class or not.

First Step: The Sorting

In this first step we will loop through our Page objects to create two distinct arrays. The first, $ordered_pages, will be a plain array of top-level Page objects, the second, $pages_by_parent, will be a multidimensional array instead, its elements will be arrays of Page objects united by the same value of post_parent.

It is essential for the actualisation of the second step that the keys of $ordered_pages are equal to the ID attribute of the Page objects contained in the array itself, and that the first keys of the multidimensional $pages_by_parent array are equal to the values the post_parent attribute takes for each group of child pages contained in $pages_by_parent.

// Variable that will store the ordered array of Page objects.
$ordered_pages = array();

// Temporary array where the elements are arrays of Page objects
// having all the same value of 'post_parent' (child pages).
$pages_by_parent = array();

// Associative array used to temporarily collect all 
// the ids of the Page objects to order.
$ids = array();

// We populate the $ids array and, to make the most out of the loop,
// we also sanitise the Page objects.
// This preliminary step is of paramount importance in ensuring that
// the loop we'll meet in the second section of this post it actually ends,
// instead of degenerating into an infinite iteration.
foreach ( $pages as $page ) {
    $page->ID          = (int) $page->ID;
    $page->post_parent = (int) $page->post_parent;
    
    // The ID of each Page object is stored both as key and as value.
    $ids[$page->ID] = $page->ID;
}

foreach ( $pages as $page ) {
    if (
        // When 'post_parent' is 0, it means that the object is a top-level Page.
        ( $page->post_parent === 0 ) ||

        // This condition checks whether the Page's parent is among the elements 
        // of our array of Page objects, if it isn't, the Page is an "orphan" 
        // and so it is treated as a top-level Page.
        !isset( $ids[$page->post_parent] )
    ) {
        $ordered_pages[$page->ID] = $page;
    }
    else {
        // Notice that the first keys of $pages_by_parent are equal 
        // to the values the 'post_parent' attribute takes for 
        // each group of Page objects.
        $pages_by_parent[$page->post_parent][$page->ID] = $page;
    }
}

Second Step: The Merging

To recap, completing the reordering involves merging $pages_by_parent with $ordered_pages, array that still contains only top-level Page objects.

Thus what needs to be done now is to insert the groups of objects contained in $pages_by_parent in the appropriate positions of the $ordered_pages array, and repeat the task for each level of nesting.

The choice made in the first step about the keys used for the two arrays to merge is of prime importance in ensuring that the usage of server resources is kept at bay.

// With each iteration, the content of one or more arrays 
// making up $pages_by_parent is joined to the content of $ordered_pages.
while( $pages_by_parent ) {
    // Temporary array used to merge the groups of child pages contained 
    // in $pages_by_parent with the Page objects in $ordered_pages.
    $array = array();
    
    foreach( $ordered_pages as $page_id => $page ) {
        $array[$page_id] = $page;
        
        // Checks whether the current $page object has child pages.
        if ( isset( $pages_by_parent[$page_id] ) ) {
            // Appends a group of child pages to the temporary array.
            foreach ( $pages_by_parent[$page_id] as $child_page_id => $child_page ) {
                $array[$child_page_id] = $child_page;
            }

            // This is essential for the While loop to draw to an end.
            unset( $pages_by_parent[$page_id] );
        }
    }

    // With each execution of the loop, they are inserted into $ordered_pages
    // as much Page objects as each level of nesting is composed of. 
    $ordered_pages = $array;
}

Using Native PHP Functions

Implementing this second step in a raw way has been only a personal preference, in fact the code can be rewritten by using native PHP functions without too much troubles, although, I wouldn't swear performance would benefit.

I leave you with an example of said alternative:

// With each iteration, are inserted into $ordered_pages
// as much child pages as each level of nesting is composed of. 
while( $pages_by_parent ) {
    foreach( $pages_by_parent as $parent_id => $child_pages ) {
        // Checks whether the current group of child pages has
        // a parent in $ordered_pages.
        if ( isset( $ordered_pages[$parent_id] ) ) {
            $insert_position = 1 + array_search( $parent_id, 
                                                 array_keys( $ordered_pages ) );
            
            // We split $ordered_pages into two distinct arrays according to 
            // the computed value of $insert_position.
            // To perform such task we use array_slice() so as to preserve
            // the numeric keys.
            $ordered_pages_head = array_slice( $ordered_pages, 0, 
                                               $insert_position, true );
            $ordered_pages_tail = array_slice( $ordered_pages, $insert_position,
                                               null, true );
            
            // Using the + operator for the merging ensures that
            // the numeric keys are preserved.
            $ordered_pages = $ordered_pages_head + $child_pages;

            // Appends $ordered_pages_tail only if it isn't an empty array.
            if ( $ordered_pages_tail ) {
                $ordered_pages += $ordered_pages_tail;
            }

            // This is essential for the While loop to draw to an end.
            unset( $pages_by_parent[$parent_id] );
        }
    }
}