PHP 8.6's SortDirection Enum: Typed Sorting in Laravel Queries and Collections

The PHP 8.6 SortDirection enum, what unbacked costs you, and how Laravel 13's query builder and collections already accept it directly in orderBy and sortBy.

Steven Richardson
Steven Richardson
· 9 min read

Someone sends ?sort=name&direction=ascending to your index endpoint. The string goes straight into orderBy('name', 'ascending'), and depending on your Laravel version that is either an exception or a column ordered the wrong way. The bug is the string. PHP 8.6 finally gives every PHP project one shared type to replace it, and Laravel 13 has already wired it into the query builder.

What PHP 8.6 actually added#

The whole feature is four lines. RFC: enum SortDirection by Tim Düsterhus was accepted 22–2 in March 2026 and ships in PHP 8.6 on 19 November 2026:

<?php

enum SortDirection {
    case Ascending;
    case Descending;
}

That is it. No methods, no interface, no backing values. It lives in the global namespace, so in a namespaced file you either write \SortDirection or add use SortDirection; at the top. Forgetting that is the first thing that will catch you out, because SortDirection::Ascending inside App\Http\Controllers resolves to App\Http\Controllers\SortDirection and fatals.

The motivation is interoperability. Before 8.6 we had SORT_ASC/SORT_DESC (only array_multisort() understands them), SCANDIR_SORT_ASCENDING/SCANDIR_SORT_DESCENDING (only scandir()), bare 'ASC'/'DESC' strings, bool $ascending flags, and separate sort()/rsort() functions. Doctrine got tired of it and shipped Doctrine\Common\Collections\Order two years ago. So did almost everyone else, which is exactly the duplication the RFC set out to kill.

Why the PHP 8.6 SortDirection enum is unbacked, and what that costs you#

The RFC is explicit: there is no single obvious canonical serialisation. SQL wants ASC, Laravel wants asc, a JSON:API query string wants a - prefix, array_multisort() wants an integer. Baking one of those into the language would have made the enum wrong for everyone else, so the cases carry no value and each consumer maps as it sees fit.

The cost lands on you immediately. An unbacked enum has no ::from(), no ::tryFrom() and no ->value. If you reach for the habits you built with backed enums in Laravel models, none of them are there:

SortDirection::tryFrom('asc');  // Error: Call to undefined method
SortDirection::Ascending->value; // Error: Undefined property

You also cannot add methods to it. It is a core enum, so there is no toSql() to attach — the enum methods and interfaces pattern that works so well on your own enums is off the table here. The replacement is a plain static mapper, and because the enum is universal you only need one of these in a codebase instead of one per project enum:

<?php

namespace App\Support;

use SortDirection;

final class SortDirections
{
    /** Map the enum to the string Laravel's grammar expects. */
    public static function toSql(SortDirection $direction): string
    {
        return match ($direction) {
            SortDirection::Ascending => 'asc',
            SortDirection::Descending => 'desc',
        };
    }

    /** Parse an API-facing value. Accepts `asc`, `desc`, and the `-column` prefix convention. */
    public static function parse(string $value): SortDirection
    {
        return match (strtolower($value)) {
            'asc', 'ascending' => SortDirection::Ascending,
            'desc', 'descending' => SortDirection::Descending,
            default => throw new \InvalidArgumentException("Unknown sort direction [{$value}]."),
        };
    }
}

match on an enum is exhaustive — add a third case to SortDirection upstream and this throws UnhandledMatchError rather than silently returning the wrong thing. With two cases that will never happen, but the habit is worth keeping.

Laravel 13 already accepts it in orderBy()#

This is the part the PHP release notes will not tell you. PR #59865 landed in Laravel 13.x and widened the query builder to take the enum directly, with symfony/polyfill-php86 pulled in as a framework dependency so it works on PHP 8.4 and 8.5 too. The current signature:

// Illuminate\Database\Query\Builder
public function orderBy($column, $direction = SortDirection::Ascending)

Note the default changed from 'asc' to SortDirection::Ascending. Inside, it normalises:

$direction = match (true) {
    $direction instanceof SortDirection => match ($direction) {
        SortDirection::Ascending => 'asc',
        SortDirection::Descending => 'desc',
    },
    strtolower($direction) === 'asc' => 'asc',
    strtolower($direction) === 'desc' => 'desc',
    default => throw new InvalidArgumentException('Order direction must be a SortDirection, "asc" or "desc".'),
};

So in a Laravel 13 app you can skip the mapper entirely for queries:

use SortDirection;

User::query()
    ->orderBy('last_name', SortDirection::Ascending)
    ->orderBy('created_at', SortDirection::Descending)
    ->paginate();

reorder() takes it too. Keep SortDirections::toSql() around anyway — you still need it the moment a direction has to reach a raw expression, a cache key, a URL, or a package that has not caught up.

Collections take it as the $descending argument#

Collection::sortBy() was widened in the same release, but in a less obvious place. The enum goes in the third argument, where the boolean used to live:

$users->sortBy('last_name', SORT_REGULAR, SortDirection::Descending);

Internally that is a single match over false, SortDirection::Ascending => asort(...) and true, SortDirection::Descending => arsort(...), so the old booleans still work. Multi-column sorts via sortBy() accept the enum in the comparison tuples as well:

$users->sortBy([
    ['last_name', SortDirection::Ascending],
    ['created_at', SortDirection::Descending],
]);

sortByDesc() is unchanged and takes no direction at all. If you are holding a SortDirection and calling the dedicated helpers, that is a branch rather than an argument:

$sorted = $direction === SortDirection::Descending
    ? $users->sortByDesc('last_name')
    : $users->sortBy('last_name');

Validating the sort parameter at the request boundary#

Here is the gotcha that cost me twenty minutes. Rule::enum(SortDirection::class) rejects every string you give it. Laravel's Enum validation rule guards with method_exists($this->type, 'tryFrom'), and an unbacked enum does not have tryFrom(), so passes() returns false before it looks at the value. It is not that the rule matches case names — it matches nothing at all.

Validate the wire format instead, then map:

<?php

namespace App\Http\Requests;

use App\Support\SortDirections;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use SortDirection;

class ListUsersRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'sort' => ['sometimes', Rule::in(['last_name', 'created_at', 'email'])],
            'direction' => ['sometimes', Rule::in(['asc', 'desc'])],
        ];
    }

    /** Hand the controller a typed value, not a string. */
    public function direction(): SortDirection
    {
        return SortDirections::parse($this->string('direction', 'asc')->toString());
    }
}

The allow-list on sort matters more than the direction rule does, by the way — orderBy() validates the direction for you but will put an arbitrary column name into SQL. Same discipline as any other JSON:API-style resource endpoint.

The controller then never sees a string:

public function index(ListUsersRequest $request)
{
    return UserResource::collection(
        User::query()
            ->orderBy($request->string('sort', 'last_name'), $request->direction())
            ->paginate()
    );
}

A Pest test that pins both directions and the rejection:

use SortDirection;

it('orders ascending and descending', function (string $input, SortDirection $expected) {
    expect(SortDirections::parse($input))->toBe($expected);
})->with([
    ['asc', SortDirection::Ascending],
    ['DESC', SortDirection::Descending],
]);

it('rejects an unknown direction', function () {
    SortDirections::parse('sideways');
})->throws(InvalidArgumentException::class);

it('rejects a bad direction at the request boundary', function () {
    $this->getJson('/api/users?direction=ascending')
        ->assertJsonValidationErrorFor('direction');
});

Replacing your own enum#

If you already have an App\Enums\SortDirection, deleting it is mostly mechanical, but it is not a pure find-and-replace. Yours was almost certainly backed with 'asc'/'desc', so every ->value and ::from() call site needs the mapper instead. A custom Rector rule handles the class rename and the ->value → SortDirections::toSql() rewrite in one pass, which beats grepping; automating Laravel upgrades with Rector covers the setup if you have not run it before.

Two things to check before you commit:

  • Your enum had methods. Anything hanging off it — labels, icons, a ->opposite() — has to move to the static helper or a decorator.
  • It crossed an API boundary. If your enum serialised to "asc" in a JSON response, the core one serialises to "Ascending", because json_encode() on an unbacked case uses the case name. That is a contract change your clients will notice. Map explicitly on the way out.

Gotchas and Edge Cases#

The global namespace. use SortDirection; in every namespaced file, or fully qualify it. There is no App\…\SortDirection fallback and the error is a fatal, not a warning.

No core function consumes it yet. array_multisort() still wants SORT_ASC, scandir() still wants SCANDIR_SORT_ASCENDING. The RFC parks this under future scope — widening those signatures can happen without a new RFC, but it has not happened in 8.6. If you expected array_multisort($rows, SortDirection::Descending) to work, it does not.

Laravel 13 broke some packages. Because orderBy() now defaults to the enum and the framework uses it internally, packages that override the builder and call strtolower($direction) blew up with strtolower(): Argument #1 ($string) must be of type string, SortDirection given. PHPStan users on prefer-latest also started seeing expects 'asc'|'desc'|SortDirection, 'DESC' given because the uppercase strings that used to pass are no longer in the type. Worth a look if you maintain anything that touches the query builder — the Laravel 12 to 13 upgrade guide covers the rest of the surface.

Rule::enum() silently fails closed. Covered above, but it is worth repeating because the failure mode is a validation error you will blame on your own regex first. Unbacked enum, no tryFrom(), rule never passes.

178 projects already declare the name. The RFC counted that many enum|class SortDirection declarations on GitHub, both namespaced and global. If yours is global and unnamespaced, 8.6 will fatal on the redeclaration. Namespace it or delete it.

Wrapping Up#

Add use SortDirection;, pass the enum to orderBy() and sortBy() directly on Laravel 13, and keep one static mapper for everything that still needs a string. Validate asc/desc with Rule::in() and map in the form request — Rule::enum() will not do it for you. If you are planning the move to 8.6, the PHP 8.6 upgrade checklist for Laravel apps has the deprecations and CI changes, and partial function application is the release's bigger win.

FAQ#

What is the SortDirection enum in PHP 8.6?

It is a two-case enum, Ascending and Descending, added to PHP's standard library in the global namespace. It exists so that frameworks, libraries and application code can all converge on one type for expressing sort direction instead of each defining their own. It carries no methods and no backing values — it is deliberately just a shared vocabulary.

Is SortDirection a backed enum?

No, it is a unit (unbacked) enum. The RFC chose that on purpose, because there is no canonical string or integer for sort direction — SQL wants ASC, Laravel wants asc, array_multisort() wants an integer constant. That means there is no ::from(), no ::tryFrom() and no ->value, so you map cases to scalars yourself with a match expression.

Does array_multisort() accept SortDirection in PHP 8.6?

No. array_multisort() still takes SORT_ASC and SORT_DESC, and scandir() still takes SCANDIR_SORT_ASCENDING and SCANDIR_SORT_DESCENDING. The RFC lists widening those signatures under future scope, and notes it could be done without a further RFC, but nothing in core consumes the enum in 8.6.

How do I use SortDirection with Laravel's orderBy()?

In Laravel 13 you pass it straight in: orderBy('created_at', SortDirection::Descending). The query builder's signature is SortDirection|'asc'|'desc' and the default is now SortDirection::Ascending, with the framework normalising the enum to asc/desc internally. On older Laravel versions, map it to a string first with a small match helper.

Should I replace my own sort direction enum with PHP's?

Usually yes, because it makes your public package signatures interoperable with everyone else's. The migration is not a pure rename though: your enum was probably backed, so every ->value and ::from() call needs a mapper, and any methods you hung off it have to move elsewhere. Check your JSON output too — the core enum serialises to "Ascending", not "asc".

Can I validate a request sort parameter into a SortDirection?

Not with Rule::enum(SortDirection::class) — Laravel's Enum rule calls tryFrom(), which unbacked enums do not have, so it rejects every string. Validate the wire format with Rule::in(['asc', 'desc']) and convert to the enum in the form request, either in an accessor or in prepareForValidation(). That keeps the string at the boundary and gives your controller a typed value.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.