January 30th, 2025 Laravel PHP

Laravel's missing RouteRegistrar component

Uh-oh! It looks like it’s been almost a year since my last blog post. Time truly flies when you’re focused on achieving personal goals. But now it’s time to break the silence with a fresh suite of useful tips and tricks you can apply to your daily routine when working with Laravel.

Wouldn’t it be nice to learn something new—something no one in the Laravel community has ever done before (probably)? I can already feel the excitement from miles away. If that sounds good, read on!

The Problem

In order to fully appreciate the solution, we first need to understand the context and the problem it is trying to solve.

Let’s set the scene: You’re a package developer. You’ve put in the work, designed a slick set of functionalities, and bundled them neatly into your package. Naturally, these features need a way for users to interact with them. After all, what good is your package if no one can call its logic?

So, like any responsible developer, you register your Controllers with the Router component. It’s straightforward and works like a charm:

Route::post('login', LoginController::class)->name('login');

But then, your package grows, and suddenly you’ve got a whole collection of routes:

Route::post('logout', LogoutController::class)->name('logout');
Route::get('me', AuthenticatedUserController::class)->name('me');
Route::post('refresh', RefreshController::class)->name('refresh');
Route::get('profile', ProfileController::class)->name('profile');

// insert 6 other route definitions

Some routes even have their own middleware:

Route::post('logout', LogoutController::class)
    ->middleware('auth')
    ->name('logout');

It works, but as your routes pile up, you start to feel that familiar developer itch. Your users might mess this up (PEBKAC issues, anyone?). So, you decide to streamline everything. You create a routes.php file, define your routes there, and hook it up in your package’s ServiceProvider.

Fast forward to the next day. You wake up, check your GitHub repo, and... there’s a new issue. A user wants to add prefixes to your routes. Okay, fair enough. You’re accommodating. You make it happen:

Route::prefix(config('package.prefix'))
    ->group(static function () {
        // all of your routes
    });

Boom. Another release tagged. Crisis averted. Or so you think.

The next day, another user chimes in: "Can I add custom names to your routes?" Annoyed, but not defeated, you oblige as a responsible OSS developer. You update your routes with another layer of configurability:

Route::prefix(config('package.prefix'))
    ->name(config('package.name'))
    ->group(static function () {
        // all of your routes
    });

You push the change, tag the release, and decide to take a well-deserved stroll. You’re finally feeling some peace. But peace is fleeting. You return to find yet another issue: someone’s asking for subdomain support. Subdomains! Your patience is wearing thin. Eyes twitching, you soldier on and add more configuration:

Route::name(config('package.name'))
    ->prefix(config('package.prefix'))
    ->domain(config('package.domain'))
    ->group(static function () {
        // all of your routes
    });

And just when you think it’s over, the floodgates open. Middleware? Defaults? More route properties? Sure, why not! You cram every conceivable option into your configuration file to satisfy the requests:

Route::middleware(config('package.middleware'))
    ->prefix(config('package.prefix'))
    ->domain(config('package.domain'))
    ->name(config('package.name'))
    ->defaults(config('package.defaults'))
    ->group(static function () {
        // all of your routes
    });

By now, your configuration file looks like it belongs in an enterprise SaaS application. You tag yet another release and go to bed, praying this will finally appease the masses.

But sleep doesn’t come easy. Instead, you’re haunted by nightmares of new GitHub issues:

  • “Can we define routes based on user roles?”
  • “Can you support nested route groups?”
  • “What about multi-tenancy?”

You wake up in a cold sweat, realizing this approach is spiraling out of control. Surely there’s a better way. There has to be a better way.

And you know what? There is.

Enter RouteRegistrars

Laravel, unfortunately, doesn’t provide a built-in concept of RouteRegistrars (for use in userland code). But that doesn’t mean we can’t introduce one ourselves. These lightweight objects handle just the essentials—registering the bare minimum with the Router component to ensure that our package’s endpoints remain functional and accessible.

This approach resolves all the issues we encountered earlier. Instead of forcing every possible customization into configuration files, we define self-contained classes where package routes are set up. This not only keeps things tidy but also allows package consumers to decorate routes however they see fit—whether that’s adding prefixes, subdomains, or custom names.

First, we need to submit a tiny pull request to add the Tappable trait to Laravel’s Router component. Quick and easy—thanks, Taylor!

With that out of the way, let’s define the routes for our imaginary authentication package:

namespace ImaginaryAuth\Http\Routing;

final readonly class RouteRegistrar
{
    public function __invoke(Router $router): void
    {
        $router->post('login', LoginController::class)->name('login');
        $router->post('logout', LogoutController::class)->name('logout');
        $router->get('me', AuthenticatedUserController::class)->name('me');
        $router->post('refresh', RefreshController::class)->name('refresh');
        $router->get('profile', ProfileController::class)->name('profile');
        
        // insert 6 other routes...
    }
}

That’s it—just the essentials. No unnecessary clutter. Now, package users can register these routes in their own applications like this:

// api.php — USERLAND CODE
<?php declare(strict_types=1);

use Illuminate\Routing\Router;

/** @var Router $router */
$router->tap(new \ImaginaryAuth\Http\Routing\RouteRegistrar());

But what about customization? Users love tweaking every possible aspect of their setup. Instead of relying on config-driven bloat, we let them wrap the RouteRegistrar in a route group:

$router
    ->domain('api.myapp.com')
    ->prefix('v1')
    ->name('api.v1.auth.')
    ->group(function (Router $router) {
        $router->tap(new \ImaginaryAuth\Http\Routing\RouteRegistrar());
    });

Now, when they run php artisan route:list, they’ll see:

POST api.myapp.com/v1/auth/password .............. api.v1.auth.password  ImaginaryAuth\Http\Controllers\ChangePasswordController
POST api.myapp.com/v1/auth/password/forgot ....... api.v1.auth.password.forgot  ImaginaryAuth\Http\Controllers\SendPasswordResetLinkController
POST api.myapp.com/v1/auth/password/reset ........ api.v1.auth.password.reset  ImaginaryAuth\Http\Controllers\ResetPasswordController
POST api.myapp.com/v1/auth/token ................. api.v1.auth.login  ImaginaryAuth\Http\Controllers\LoginController
POST api.myapp.com/v1/auth/token/refresh ......... api.v1.auth.refresh  ImaginaryAuth\Http\Controllers\RefreshController

Neat, isn’t it? No more configuration overload. No more nightmares about endless GitHub issues. Just a clean, elegant solution that gives package users all the flexibility they need without turning your package into a bloated mess.

And with that, I can finally get a peaceful night’s sleep. 😌

Join the discussion on X (formerly Twitter)! I'd love to know what you thought about this blog post.

Thanks for reading!