Timeline Taxi Out now: my sci-fi novel Timeline Taxi is published!

The case for route attributes

I've been thinking about route attributes lately. By doing so, I came to realise that I've got a somewhat strange relation with annotations a.k.a. attributes. Over the years, I've gone from loving them to hating them, to loving them again, to somewhere in between. I've seen them abused, both inside and outside of PHP and I've heard both advocates and opponents making compelling arguments for and against them.

Lately, I've done quite a lot of thinking about a specific use case for them. I've talked with a bunch of people about it and I've tried to approach the question as rationally as possible: are attributes used for routing, a good or bad idea?

After months of thoughts and discussions, I've come to (what I think to be as objectively as possible) a conclusion: they are worth giving a try, albeit with some side notes attached. In this post, I'll share my thought process, as well as address all counterarguments I've heard against route attributes over these past years.

Let's get started.


To make sure we're on the same page, route attributes in their most basic form would look something like this:

class PostAdminController
{
    #[Get('/posts')]
    public function index() {}
    
    #[Get('/posts/{post}')]
    public function show(Post $post) {}
    
    // …
    
    #[Post('/posts/{post}')]
    public function store(Post $post) {}
}

There are a lot of issues with such a simplified example, so let's go through them one by one.

# Duplication

First of all, there's the issue of duplication. It might not seem like a problem in this example, but most projects definitely have more than "a few routes". I've counted them in two of the larger projects I'm working on: 470 and 815 routes respectively.

Both Symfony and Laravel have a concept called "route groups" to deal with these kinds of scaling issues. And the same thinking can be applied when using route attributes.

I'm sure you can come up with quite a lot of different approaches to modeling such attribute route groups; I'm going to share two that I think are robust and qualitative solutions, but it's by no means a definitive list.

You could manage "shared route configuration", stuff like prefixes and middlewares, on the controller level:

#[Prefix('/posts')]
#[Middleware(AdminMiddleware::class)]
class PostController
{
    #[Get('/posts')]
    public function index() {}
    
    #[Get('/posts/{post}')]
    public function show(Post $post) {}
}

Or, taking it a step further; have a generic Route attribute that can be used like so:

#[Route(
    prefix: '/post',
    middleware: [AdminMiddleware::class]
)]
class PostController
{
    #[Get('/posts')]
    public function index() {}
    
    #[Get('/posts/{post}')]
    public function show(Post $post) {}
}

But also make it extensible:

#[Attribute]
class AdminRoute extends Route
{
    public function __construct(
        string $prefix,
        array $middleware,
    ) {
        parent::__construct(
            prefix: "/admin/{$prefix}",
            middleware: [
                AdminMiddleware::class,
                ...$middleware
            ],
        )
    }
}

And be used like so:

#[AdminRoute]
class PostController
{
    #[Get('/posts')]
    public function index() {}
    
    #[Get('/posts/{post}')]
    public function show(Post $post) {}
}

This last approach is definitely my favourite, but feel free to differ in that opinion. The main point here is: excessive duplication doesn't have to be a problem with route attributes.

# Discoverability

The second-biggest argument against route attributes comes from people who say that they prefer to keep their routes in a single file, so that they can easily search them, instead of spreading them across potentially hundreds of controller files.

Let's take a look at a real life example though. Here we have a contacts controller with an edit method:

class ContactsController
{
    public function edit(Contact $contact) 
    { /* … */ };
}

People arguing for "a central place to manage their routes", in other words: against route attributes; say that a central route file makes it easier to find what they are looking for. So, ok, let's click through to our routes file (in my case the Laravel IDEA plugin allows you to click the edit method and go straight to the route definition), and take a look at what's there:

Route::get('{contact}', [ContactsController::class, 'edit']);

So, what's the URI to visit this page? Is it /{contactId}? Of course not, this route is part of a route group:

Route::prefix('people')->group(function (): void {
    // …
    Route::get('{contact}', [ContactsController::class, 'edit']);
});

So, it's /people/{contactId}? Nope, because this group is part of another group:

Route::prefix('crm')
    // …
    ->group(function (): void {
        Route::prefix('people')->group(function (): void {
            // …
            Route::get('{contact}', [ContactsController::class, 'edit']);
        });
    }

Which is part of another group:

Route::middleware('can:admin,' . Tenant::class)
    ->group(function (): void {
        Route::prefix('crm')
        // …
        ->group(function (): void {
            Route::prefix('people')->group(function (): void {
                // …
                Route::get('{contact}', [ContactsController::class, 'edit']);
            });
        }

Which is part of another group, defined in Laravel's route service provider:

Route::middleware(['web', 'auth', /* … */])
    ->prefix('admin/{currentTenant}')
    ->group(base_path('routes/admin_tenant.php'));

So, in fact, the full URI to this controller is /admin/{tenantId}/crm/people/edit/{contactId}. And now remember our route file actually contains somewhere between 700 and 1500 lines of code, not just the snippets I shared here.

I'd argue that using dedicated route attributes like CrmRoute extending AdminRoute would be much easier to work with, since you can simply start from the controller and click your way one level up each time, without manually looking through group configurations.

Furthermore, adding a route to the right place in such a large route file poses the same issue: on what line exactly should my route be defined to fall in the right group? I'm not going to step through the same process again in reverse, I'm sure you can see the problem I'm pointing at.

Finally, some people mention splitting their route files into separate ones to partially prevent these problems. And I'd agree with them: that's exactly what route attributes allow you to do on a controller-based level.

In short, dedicated route files do not improve discoverability and route attributes definitely don't worsen the situation.

# Consistency

With the two main arguments against route attributes refuted, let's consider whether they have additional benefits compared to separated route files.

Spoiler: they do.

In the vast majority of cases, from my own experience and based on other's testimonies, controller methods and URIs almost always map one to one together. So why shouldn't they be kept together?

When I'm writing a new controller method, the last thing I want to be bothered about is to create the controller method, and then having to think about "ok, which route file should I now go to that has the correct middleware groups setup, and where in that file should I register this particular method". There's so much unnecessary cognitive overload introduced because of separate route files, because they pull apart two concepts they tightly belong together.

Let's just keep them together, so that we can focus on more important stuff.

Furthermore, any framework worth its salt will provide you with the tools necessary to generate any URI based on a controller method:

action([PostController::class, 'show'], $post);

If you're already working with controller methods as the "entry point" into your project's URI scheme, then why not keep relevant meta data right with them as well?

So yes, route attributes do add value compared to route files: they reduce cognitive load while programming.

# Route collisions

One of the only arguments against route attributes that I kind of agree with, is how we deal with collisions. You've probably dealt with a situation like this one, where two route definitions collide with each other:

Route::get('/contacts/{id}', …);
Route::get('/contacts/list', …);

Here we have a classic collision: when visiting /contacts/list, your router could detect it as matching /contacts/{id}, and in turn runs the wrong action for that route.

Such problems occur rarely, but I've had to deal with them myself on the odd occasion. The solution, when using a single route file, is to simply switch their order:

Route::get('/contacts/list', …);
Route::get('/contacts/{id}', …);

This makes it so that /contacts/list is the first hit, and thus prevents the route collision. However, you don't have any control over the route order when using attributes since they are directly coupled to controller methods and not grouped together; so what then?

First of all, there are a couple of ways to circumvent route collisions, using route files or attributes, all the same; that don't require you to rely on route ordering:

However, there still might be some edge cases where collisions are unavoidable. How to handle those? The most obvious solution is to simply allow some kind of "order" key on route attributes, so that you can carefully control their order yourself:

#[Get('/contacts/list', order: 'contacts-1')]
public function index() {}

#[Get('/contacts/{id}', order: 'contacts-2')]
public function show(Contact $contact) {}

I agree that this approach isn't ideal, but I'd say that solving route collisions never is. On top of that, these kinds of collisions only rarely happen, so I only consider it a very minor problem that can be solved when needed.

# Performance

Finally a short one, but one that needs mentioning: some people are afraid of attributes because of performance issues.

First of all: reflection in PHP is pretty fast, all major frameworks use reflection extensively, and I bet you never noticed those parts being a performance bottleneck.

And, secondly: attribute discovery and route registration is something that is very easily cacheable in production: Laravel already does this with event listeners and blade components, just to name two examples.

In fact, the concept of "a route cache" is already present in both Symfony and Laravel, and Symfony even already supports route attributes.

So no, performance isn't a concern when using route attributes.

Noticed a tpyo? You can submit a PR to fix it. If you want to stay up to date about what's happening on this blog, you can subscribe to my mailing list: send an email to brendt@stitcher.io, and I'll add you to the list.

So, what's left? The only argument I've heard that I didn't address here is that "people just don't like attributes".

There's very little to say against that. I think it mostly means that "people don't like change" in general. I've been guilty of this attitude myself. The only advice I can give, if you're in that situation, is to just try it out. Get out of your comfort zone, it's a liberating thing to do.

Now, maybe you want to tell me I'm wrong, or share your own thoughts on the matter. I'd love for my opinion to be challenged, so feel free to share your thoughts on Twitter or via email!