DEV Community

Rapolas Gruzdys
Rapolas Gruzdys

Posted on • Originally published at rapolas.dev

How to structure, define and reuse Laravel routes with PHP objects

I find routing in Laravel wonderful: it's easy to understand, structured yet highly configurable and flexible,
and overall a pretty solid (pun intended) set of features. My only gripe with it is when it comes to
defining and generating/using your actual routes. Not sure about you, but I'm definitely tired of writing
route('really.long.route.name.that.is.hard.to.remember') over and over again.

At the time of writing, the route() helper only accepts a string for your desired route,
which means you're stuck with using hard coded strings for your route URLs and names.
Or maybe, there just aren't any clear alternative options. Let's explore that in this article.

Since software development is all about solving problems, let's start with a real-life example:

The problem

Let's imagine we have a big brand, with lots of informational pages. Our brand also holds a presence on Facebook.
Our pages may have any layout we can imagine, but one requirement is certain - we'll have navigation and a footer.
We want to display URLs to our Facebook page in both of those sections.

This means, that we have the same URL/route being used in (at least) two different places -
one in the navigation and one in the footer. Depending on the content and implementation, there might be even more.

We've gone ahead and defined these external routes, in a separate route file.
If you'd like to learn about this in more detail - check out this article.
A simple setup might look something like this:

The routes definition (routes/external.php for example):

<?php

use Illuminate\Support\Facades\Route;

Route::name('facebook.')->domain('https://www.facebook.com')->group(function () {
    Route::get('/AcmeCorp/reviews')->name('reviews');
    Route::get('/AcmeCorp/about')->name('about');
});
Enter fullscreen mode Exit fullscreen mode

Let's identify what we've got here: we have a named group, consisting of a domain with two routes.

Now, all need to do is reference this route in our "navigation" and "footer" blade files.
Just type in route('facebook.about') once, then... type in route('facebook.about') the second time... and...
aaand we're duplicating ourselves. What happens when the application scales, and we need to reference visitors to
our FB page over, and over again? Or need to scatter "contact us" links around some articles?

Not to mention, unless you're using special (and sometimes paid) plugins, your IDE doesn't autocomplete this for you.
And you definitely don't remember all of your route names, which means you'll go over to your routes file,
search for your route, copy its name (or rather construct it since it is nested within three named groups),
go back to your original file and paste it in. Loads of fun.

Oh, you remember the name by heart, I hear you say? I'm sure every single one of us has acquainted ourselves with
the occasional typo. Don't want that, no, thank you.

And to top it all off - just imagine how many hard coded strings you'll have to change, once you eventually refactor
your route definitions. That would be a poor excuse, to not change your route group's name
to something more descriptive down the road.

So, what do we do?! Well, we could potentially use class constants to store our route names.
That could work, yes - we have a single defined string, which we're free to modify and our IDE autocompletes,
so we avoid typos. We can also easily find usages of the route!
But take a look at our route definition example once again. We have nested our routes in groups.

If we want to use class constants, we're forced to ditch this feature and define the full name
for every route definition, for example ->name(group.domain.resource.list), ->name(group.domain.resource.show) etc.
If we want to keep the feature, then we have to define route name prefixes in our class constants,
which I wouldn't really call "elegant". Take a look:

<?php

namespace App\Routing\Routes;

class FbRoutes
{
    // if we're not using constants, then we're duplicating the strings
    public const GROUP_NAME_FB = 'facebook.';
    // ... maybe more groups here ...

    public const NAME_REVIEWS = 'reviews';
    public const NAME_ABOUT = 'about';

    public const FULL_NAME_REVIEWS = self::GROUP_NAME_FB . self::FULL_NAME_REVIEWS;
    public const FULL_NAME_ABOUT = self::GROUP_NAME_FB . self::NAME_ABOUT;
}
Enter fullscreen mode Exit fullscreen mode

Seems like this would be difficult to organize. Think how your route definition would look like šŸ¤”.

Okay, so class constants are definitely a no-go. Alright, enough teasing. Seems like we could overcome all of
these issues by using something OOP languages do best - objects! Here's what I've found works rather well:

The solution

Currently, I've got two solutions which are very similar in terms of results. Both of them have a similar structure
and use case. Yet both of them come with pros and cons at the moment of writing. The first uses classes and requires more
boilerplate but is easier to reference. The other uses PHP 8.1 Enumerations - there's significantly less
boilerplate but has a caveat when it comes to referencing them.
I think that both of these are great approaches, so let's hit it off with classes:

Class approach

Basically, all we need is a class with some static methods, which abstract all of this string concatenation logic.
A similar approach can be found with the Feature ... erm feature... in
Laravel Jetstream.
For example, a method call to Feature::teams() just returns the name string of the feature: 'teams'.
This means that we can define simple static methods, and call a bunch of logic within them to construct our route
names however we please: a full name, only the name within the group, or even a URI.
It may look something like:

<?php

namespace App\Routing\Routes;

class FbRoutes
{
    public const GROUP_NAME = 'facebook.';

    public const NAME_REVIEWS = 'reviews';
    public const NAME_ABOUT = 'about';

    public function __construct(private string $routeName)
    {
    }

    public static function reviews(): self
    {
        return new self(self::NAME_REVIEWS);
    }

    public static function about(): self
    {
        return new self(self::NAME_ABOUT);
    }

    public function name(): string
    {
        return $this->routeName;
    }

    public function fullName(): string
    {
        return self::GROUP_NAME . $this->routeName;
    }

    public function path(): string
    {
        $paths = [
            self::NAME_REVIEWS => '/AcmeCorp/reviews',
            self::NAME_ABOUT => '/AcmeCorp/about',
        ];

        return $paths[$this->routeName];
    }

    public function __toString(): string
    {
        return $this->fullName();
    }
}
Enter fullscreen mode Exit fullscreen mode

Here's how you can define your routes:

<?php

use App\Routing\Routes\JobRoutes;
use Illuminate\Support\Facades\Route;

Route::name(FbRoutes::GROUP_NAME)->group(function () {
    Route::get(FbRoutes::reviews()->path())->name(FbRoutes::reviews()->name());
    Route::get(FbRoutes::about()->path())->name(FbRoutes::about()->name());
});
Enter fullscreen mode Exit fullscreen mode

Thanks to the __toString() method, you may now generate your routes with a simple method call
like route(FbRoutes::about()). Neat, this checks all the boxes:

  • No more hard coded strings, so our code is DRY and free of typos
  • Our code is easy to modify without refactoring a bunch of files
  • We get autocompletion from the IDE
  • The code definition for a class with methods has much less cognitive complexity.

The definition, however, is still rather extensive: it takes at least 4 lines to define a new route (5 if you
include the constant), and there's comparatively a lot of "logic" for the boilerplate. We can make this better!

Enums

With the release of PHP 8.1, we can simplify the above by using Enums.
Here's why I like them a bit more for this use case:

  • They're just objects - this means they can have properties, methods and implement interfaces
  • They are instanced with a static call, similar to class constants. Sexy! šŸ˜
  • Thanks to this instancing, we have less boilerplate with the same amount of IDE support (no magic __callStatic() methods, etc.)

Here's the same approach with (backed) Enums:

<?php

namespace App\Routing\Routes;

enum FbRoutes: string
{
    public const GROUP_NAME = 'facebook.';

    case REVIEWS = 'reviews';
    case ABOUT = 'about';

    public function name(): string
    {
        return $this->value;
    }

    public function fullName(): string
    {
        return self::GROUP_NAME . $this->name();
    }

    public function path(): string
    {        
        return match($this) {
            self::REVIEWS => '/AcmeCorp/reviews',
            self::ABOUT => '/AcmeCorp/about',
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

You define the routes similar to classes:

<?php

use App\Routing\Routes\JobRoutes;
use Illuminate\Support\Facades\Route;

Route::name(FbRoutes::GROUP_NAME)->group(function () {
    Route::get(FbRoutes::REVIEWS->path())->name(FbRoutes::REVIEWS->name());
    Route::get(FbRoutes::ABOUT->path())->name(FbRoutes::ABOUT->name());
});
Enter fullscreen mode Exit fullscreen mode

Unfortunately, because enums don't support the __toString() method, we must generate our routes with an
additional method call to the Enum: route(FbRoutes::ABOUT->fullName()).
Personally, I think this is the only downfall of the Enum approach, compared to the Class one.
It's also a major bummer for me šŸ˜ž

However, this inconvenience can be somewhat mitigated. By changing our helper to generate routes, we may call
the ->fullName() method internally and call it a day! All we need is an interface to enforce the method and
typehint in the helper function.
Here's where I've faced the biggest limitation - we can not alter the route() function provided by Laravel.
This means we'd have to define a new helper function. To put it lightly - it's unconventional and therefore probably
not worth it for me personally.

In conclusion & TLDR

By now, I'm sure we're on the same page, why using hard coded strings for your route names isn't desirable.
You could also probably agree, that using simple class constants is neither "elegant" nor "flexible".
I'd say it's even messier than hard coded strings, in a way.

That's why you can leverage objects when it comes to managing your route names.
Whether it's Classes or Enums, the benefits are the same:

  • No hard coded strings, our code is DRY and free of typos
  • Code is SOLID - we only change a single class instead of many files
  • We get full IDE support

Just make sure to weigh your options between boilerplate and ease of use between the two approaches.
Personally, I favor Enums and can only hope for better support for them in the future.

Thanks for taking your time, I hope you've got some value from my post āœŒļø

Top comments (0)