The Keys are the Key in Laravel Multiple Field sortBy()

Important Addendum:

While I have never run across issues with this personally, I don’t really use multi-sorting on collections frequently and thought it was safe enough. It seems that the asort problem of unpredictable sort order when values are equal is worse than I thought, and so this is probably not a good technique to use. A PR has already been created to make a more stable version of sortBy() that will work correctly — you can read about it here: https://github.com/laravel/framework/pull/21214. The explanation below may still be interesting to you.

One of the “popular” issues over on Laravel Internals is a request to allow for sorting by multiple fields. People noticed that if you tried to sort a collection like:

dd($users->sortBy('last_name')->sortBy('first_name'));

it would seem to sort on last_name but then “copy over” the sort with the second one, leaving you with a Users collection seemingly sorted only on first name.

In fact, this is not correct. The confusion is quite understandable, but can be cleared up looking under the hood at the Collection code.

TL;DR; — if you want to sort by last name, first name, put them in reverse order.

Here’s why:

Let’s use this simple array of names for our example, so you can play along at home:

$users = collect([
[
'first_name' => 'Jeff',
'last_name' => 'Madsen',
],
[
'first_name' => 'Jeff',
'last_name' => 'Otwell',
],
[
'first_name' => 'Keith',
'last_name' => 'Damiani',
],
[
'first_name' => 'Keith',
'last_name' => 'Otwell',
],
[
'first_name' => 'Jeff',
'last_name' => 'Taylor',
],
[
'first_name' => 'Taylor',
'last_name' => 'Damiani',
],
]);
dd($users->sortBy('first_name')->sortBy('last_name'));

(TightenCo’s Keith Damiani actually posted the correct answer in Internals, so giving him a shout out.)

The code above is — counter-intuitively — correct for sorting by last name, first name. To understand what’s going on, let’s dive into the `Illuminate\Support\Collection` and look at what’s really happening with this sort.

I want you to add three dump() functions so we can see our results as we are testing. I removed the comments to make this a little briefer to read through:

public function sortBy($callback, $options = SORT_REGULAR, $descending = false)
{
$results = [];

$callback = $this->valueRetriever($callback);

foreach ($this->items as $key => $value) {
$results[$key] = $callback($value, $key);
}

dump($results);

$descending ? arsort($results, $options)
: asort($results, $options);

dump($results);

foreach (array_keys($results) as $key) {
$results[$key] = $this->items[$key];
}

dump($results);

return new static($results);
}

Let’s just sort of “blip” over the $callback stuff as being unimportant for this. It is just setting up our $items array for sorting. The first foreach() builds up a temporary variable for us to work with with a detached set of the values we wanted sorted, then does an asort(). That’s important for two reasons. An asort() maintains the key values. This will not work if you try it with a normal sort(). The detached temporary array we’ll discuss more below.

Dump the results out on the page and let’s look just at the first two array dumps for a moment:

The first two dump($result) from sortBy(‘first_name’)

Nothing fancy here — you can see the users have been sorted by first name, in the order they appeared in the original array. The thing to notice is the keys — they have been maintained, and so now are “out of order”.

This is the full data set, if we wanted to stop right now.

This is where we want to pay attention to the second foreach loop. This is looking at that key order and building a “new” array of all the data based on the keys. In other words, the last_name field is just being tagged back on according to the new order of the first_names.

Okay, we made it through the first sorting. Let’s do it again with last names and we’ll see why those things I mentioned above are so important to all of this.

The second time we go through we detach the last names with the re-ordered keys from our first pass. So we have a an array of last names that looks like this:

The last names before and after being sorted

We are maintaining the keys, and so the second sort has the overall affect of “bubbling up” the last names in the full result set I showed you above.

This is the full set before we sort on last name, just repeated for ease of reading:

This is the full data set from above not yet sorted on last name

So we have Jeff…Keith…Taylor… The last name sort will look down the list “Keith Damiani” and push him to the top, then “Taylor Damiani” and move him to second position, etc. (not exactly what is going on internally, but this is Laravel internals, not PHP). You notice that while the last names are being actively sorted, the first names are being left alone within their last name groups, and so remain sorted as a secondary key.

The final result:

The final result

That’s it! Hope that helps make it clearer why you have to do things “backwards” when sorting multiple fields.