46

I know that when using the query builder, it is possible to sort by multiple columns using

...orderBy('column1')->orderBy('column2')

but now I am dealing with a collection object. Collections have the sortBy method, but I have not been able to figure out how to make it work for multiple columns. Intuitively, I initially tried to use the same syntax as orderBy.

sortBy('column1')->sortBy('column2)

but this apparently just applies the sorts sequentially and it ends up sorted by column2, disregarding column1. I tried

sortBy('column1', 'column2')

but that throws the error "asort() expects parameter 2 to be long, string given". Using

sortBy('column1, column2')

doesn't throw an error, but the sort appears to be pretty random, so I don't really know what that actually does. I looked at the code for the sortBy method, but unfortunately I am having a hard time understanding how it works.

Don't Panic
  • 41,125
  • 10
  • 61
  • 80

4 Answers4

67

sortBy() takes a closure, allowing you to provide a single value that should be used for sorting comparisons, but you can make it a composite by concatenating several properties together

$posts = $posts->sortBy(function($post) {
    return sprintf('%-12s%s', $post->column1, $post->column2);
});

If you need the sortBy against multiple columns, you probably need to space pad them to ensure that "ABC" and "DEF" comes after "AB" and "DEF", hence the sprint right padded for each column up to the column's length (at least for all but the last column)

Note that it's generally a lot more efficient if you can use an orderBy in your query so the collection is ready-sorted on retrieval from the database

Mark Baker
  • 209,507
  • 32
  • 346
  • 385
  • Thanks @Mark Baker! That certainly works, and your answer helps me understand how sortBy works. One thing that is still confusing me is that `sortBy()` takes a closure, but I had been calling it with a string with no problems. It seems something is missing from my understanding of closures. – Don't Panic Aug 22 '14 at 16:32
  • 3
    I believe that if you pass a property name as a string rather than a closure, it will call `$this->valueRetriever($callback)` which creates a closure to use that property name..... of course, that property name must exist – Mark Baker Aug 22 '14 at 16:45
  • You could also use `implode(' ', [$post->column1, $post->column2])` instead of `sprintf`. – Leith Nov 20 '16 at 04:46
  • 1
    @Leith which one is more efficient? It would also be nice to understand what the heck that sprintf function is doing. – Jonathan Feb 14 '17 at 11:36
  • 1
    @Jonathan - i don't necessarily feel a need to explain ___what___ a [documented PHP core function does](http://www.php.net/manual/en/function.sprintf.php); but in this case I have explained ___why___ I use it (`If you need the sortBy against multiple columns, you probably need to space pad them to ensure that "ABC" and "DEF" comes after "AB" and "DEF", hence the sprint right padded for each column up to the column's length (at least for all but the last column)`) – Mark Baker Feb 14 '17 at 12:25
  • 1
    @Jonathan `sprintf()` is probably _slightly_ more efficient (i.e. if you ran both 10000 times, you might see a handful of microseconds difference). The reason I commented is that while useful in some circumstances, I view `sprintf()` as a hangover from C - its syntax is non-intuitive and can make even simple code harder to read unless you're very familiar with it. In this case the author is using it to pad whatever the column values are out to 12 characters, so the behaviour is _slightly_ different from mine. You'd need to do a 'natural sort' to avoid this kind of 'sort with spaces' hack. – Leith Feb 14 '17 at 22:10
  • all these fields will be sorted either ASC or DESC, you cannot specify style for each column, right? – Yevgeniy Afanasyev Jun 02 '17 at 04:21
  • 1
    @YevgeniyAfanasyev That all depends on how much logic you want to put in your callback; the simple callback I've shown could only be used for simple asc or desc; you could make it more complicated, but that's a whole nother question.... though the callback in derekaug's answer is more suited to enforcing asc/desc by column – Mark Baker Jun 02 '17 at 07:00
  • @MarkBaker So as someone who isn't a php pro....how can you make it sort asc or desc? It's sorting my times one way and my dates the opposite way and I'm not sure how make it specific. Can you explain it or direct me to where I need to look to figure it out? Thanks! – Sensoray Oct 20 '17 at 19:46
42

I found a different way to do this using sort() on the eloquent Collection. It may potentially work a bit better or at least be a bit easier to understand than padding the fields. This one has more comparisons but I'm not doing the sprintf() for every item.

$items = $items->sort(
    function ($a, $b) {
        // sort by column1 first, then 2, and so on
        return strcmp($a->column1, $b->column1)
            ?: strcmp($a->column2, $b->column2)
            ?: strcmp($a->column3, $b->column3);
    }
);
miken32
  • 42,008
  • 16
  • 111
  • 154
derekaug
  • 2,145
  • 1
  • 17
  • 17
13

As @derekaug mentioned, the sort method allows us to enter a custom closure for sorting the collection. But I thought his solution was somewhat cumbersome to write and it woulde be nice to have something like this:

$collection = collect([/* items */])
$sort = ["column1" => "asc", "column2" => "desc"];
$comparer = $makeComparer($sort);
$collection->sort($comparer);

In fact, this can be easily archived by the following $makeComparer wrapper to generate the compare closure:

$makeComparer = function($criteria) {
  $comparer = function ($first, $second) use ($criteria) {
    foreach ($criteria as $key => $orderType) {
      // normalize sort direction
      $orderType = strtolower($orderType);
      if ($first[$key] < $second[$key]) {
        return $orderType === "asc" ? -1 : 1;
      } else if ($first[$key] > $second[$key]) {
        return $orderType === "asc" ? 1 : -1;
      }
    }
    // all elements were equal
    return 0;
  };
  return $comparer;
};

Examples

$collection = collect([
  ["id" => 1, "name" => "Pascal", "age" => "15"],
  ["id" => 5, "name" => "Mark", "age" => "25"],
  ["id" => 3, "name" => "Hugo", "age" => "55"],
  ["id" => 2, "name" => "Angus", "age" => "25"]
]);

$criteria = ["age" => "desc", "id" => "desc"];
$comparer = $makeComparer($criteria);
$sorted = $collection->sort($comparer);
$actual = $sorted->values()->toArray();

/**
* [
*  ["id" => 5, "name" => "Hugo", "age" => "55"],
*  ["id" => 3, "name" => "Mark", "age" => "25"],
*  ["id" => 2, "name" => "Angus", "age" => "25"],
*  ["id" => 1, "name" => "Pascal", "age" => "15"],
* ];
*/

$criteria = ["age" => "desc", "id" => "asc"];
$comparer = $makeComparer($criteria);
$sorted = $collection->sort($comparer);
$actual = $sorted->values()->toArray();

/**
* [
*  ["id" => 5, "name" => "Hugo", "age" => "55"],
*  ["id" => 2, "name" => "Angus", "age" => "25"],
*  ["id" => 3, "name" => "Mark", "age" => "25"],
*  ["id" => 1, "name" => "Pascal", "age" => "15"],
* ];
*/

$criteria = ["id" => "asc"];
$comparer = $makeComparer($criteria);
$sorted = $collection->sort($comparer);
$actual = $sorted->values()->toArray();

/**
* [
*  ["id" => 1, "name" => "Pascal", "age" => "15"],
*  ["id" => 2, "name" => "Angus", "age" => "25"],
*  ["id" => 3, "name" => "Mark", "age" => "25"],
*  ["id" => 5, "name" => "Hugo", "age" => "55"],
* ];
*/

Now, since we're talking Eloquent here, chances are high that you're also using Laravel. So we might even bind the $makeComparer() closure to the IOC and resolve it from there:

// app/Providers/AppServiceProvider.php 
// in Laravel 5.1
class AppServiceProvider extends ServiceProvider
{
    /**
     * ...
     */


    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind("collection.multiSort", function ($app, $criteria){
                return function ($first, $second) use ($criteria) {
                    foreach ($criteria as $key => $orderType) {
                        // normalize sort direction
                        $orderType = strtolower($orderType);
                        if ($first[$key] < $second[$key]) {
                            return $orderType === "asc" ? -1 : 1;
                        } else if ($first[$key] > $second[$key]) {
                            return $orderType === "asc" ? 1 : -1;
                        }
                    }
                    // all elements were equal
                    return 0;
                };
        });
    }
}

Now you can use it everywhere you need to like so:

$criteria = ["id" => "asc"];
$comparer = $this->app->make("collection.multiSort",$criteria);
$sorted = $collection->sort($comparer);
$actual = $sorted->values()->toArray();
Hirnhamster
  • 7,101
  • 8
  • 43
  • 73
  • You can use the data_get helper to get the functionality of "dot notation". Very useful: if (data_get($first, $key) < data_get($second, $key)) { return $orderType === "asc" ? -1 : 1; } else if (data_get($first, $key) > data_get($second, $key)) { return $orderType === "asc" ? 1 : -1; } – Rodolfo Jorge Nemer Nogueira Jun 01 '19 at 14:54
1

A simple solution is to chain sortBy() multiple times in reverse order of how you want them sorted. Downside is this is likely to be slower than sorting at once in the same callback, so use at your own risk on large collections.

$collection->sortBy('column3')->sortBy('column2')->sortBy('column1');
dtbarne
  • 8,110
  • 5
  • 43
  • 49
  • This looks like it will only work if sortBy uses a stable sort ( http://wiki.c2.com/?StableSort ) . According to the discussion at https://github.com/laravel/internals/issues/11 this is fixed in Laravel 5.5 – bdsl Nov 21 '17 at 12:24
  • This has been revered in https://github.com/laravel/framework/pull/21255. Still no "fix" as of 13.02.2018 – spaceemotion Feb 13 '18 at 21:41