May 20, 2024Profile photoTony Masek

How to structure your routes in Laravel

Let me show you, how I like to structure routes inside my Laravel applications into multiple files and how to automate the route registration process.

One of the reasons I fell in love with Laravel was the routes file. Its simplicity - one rudimentary file, but it gives you an overview of your entire application. Back then, I came from a framework where routes were auto-discovered by controller actions and it felt a bit opaque. So this is something I really enjoy to this day.

When I started building a bit larger applications, however, I found, that I would like to give my routes a bit more structure. Just for the record - it is perfectly fine to have all routes in a single file. I can imagine that much larger apps than I’m building have routes registered in this way. Nevertheless, over time I found out, that my personal preference is to split routes into multiple files, where each file encapsulates a single entity or domain. Thanks to this I don’t have to scroll through hundreds of lines inside the main routes file, but I can navigate quite quickly between the files I need.

Of course, this comes with some caveats. One of them is the fact, that you need to be a bit more careful about the order in which the routes are being registered. But be it as is, I’m doing it this way for several years now and am quite happy with it.

Example structure

Enough talk, let’s take a look at an example. Usually, I will create two directories: routes/app and routes/public. All publicly accessible routes will be located inside the routes/public directory, while routes which require authentication will be inside routes/app directory. In practice this means, that routes inside the routes/app directory will have auth middleware. Here is a possible directory structure:

routes/

├── app/
   ├── users.php
   ├── tasks.php
   └── media.php

└── public/
    └── auth.php

Registering routes

To register routes in this way you have several options. Either you will require all route files from your main routes file - usually web.php. The second option is to register them in bootstrap/app.php (or RouteServiceProvider for Laravel 10.x and below).

My approach usually was to register them inside the RouteServiceProvider. The reason is, that I can write a bit of code to automate the registering process without having to manually import all the files and risking, that I can forget to register any new files I might add in the future.

Up until Laravel 10 I was always copying the logic from project to project. It was almost my routine. But when Laravel 11 came out, suddenly there was no RouteServiceProvider so I was forced to revisit this functionality and think about where I could house it. Suddenly the obvious choice for me was to write my own macro for the Route facade. And while I was at it I realized, that instead of copying the code between projects I should package it up for easier use.

The code

The code itself is pretty simple. I will paste the current state of the macro (20th of May, 2024):

Route::macro('loadFromDirectory', function (string $path, array $middleware = [], ?string $prefix = null, string|bool|null $name = null) {
    if (! is_null($prefix) && $name !== false) {
        $name ??= str($prefix)
            ->ltrim('/')
            ->rtrim('/')
            ->replace('/', '.');
    }

    if (! is_null($name)) {
        $name = str($name)->rtrim('.')->append('.');
    }

    $path = ! str($path)->startsWith('/')
        ? base_path($path)
        : $path;

    $files = File::exists($path)
        ? File::allFiles($path, false)
        : [];

    collect($files)
        ->filter(fn (SplFileInfo $file) => $file->getExtension() === 'php')
        ->each(function (SplFileInfo $fileInfo) use ($middleware, $prefix, $name) {
            Route::middleware($middleware)
                ->prefix($prefix)
                ->name($name)
                ->group($fileInfo->getRealPath());
        });
});

The core part is reading all visible php files inside a specified directory and registering all included routes. The rest are just a few helpers I found useful. For example setting middleware for the entire directory, or a prefix or a name.

Because the code is reading only visible files, you can use this to hide routes, that are not yet ready to be public. So imagine you have routes/app/secret.php routes file, then you can just prefix it with a dot routes/app/.secret.php and routes inside this file won’t be loaded.

Prefix

When setting the prefix all routes registered within the directory will be prefixed. So imagine we have used the macro as follows:

Route::loadFromDirectory(
    'routes/app',
	['web', 'auth'],
	'app',
);

Now all routes will have the app prefix in the URL. The default behaviour is, that when specifying a prefix it will also use it as a name. So in this instance, all routes would have app. name prefix. You can always specify your name, which will overwrite the default one, or you can set the name parameter to false which will just use the URL prefix without any name.

Usage

Laravel 11.x or later

Use it inside your bootstrap/app.php

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
        then: function () {
            Route::loadFromDirectory(
                'routes/app',
                ['web', 'auth'],
            );
        },
    )

Laravel 10.x and below

Use it inside your RouteServiceProvider.php

public function boot(): void
{
    $this->routes(function () {
        Route::loadFromDirectory(
            'routes/app',
            ['web', 'auth'],
        );
    });
}

The package

As I already mentioned I packaged this up and you can take a look at it here. I made a few changes to the code when writing the package so I will test it for a while on my own projects before tagging a stable version.

Update: v0.1.0

In this version, I added the possibility to load hidden files in specified environments. This is particularly useful when you want to work locally on new routes but don't want them registered in production. You can specify in which environments to load hidden files in the config file via the register_hidden_routes_in_environments array.

If you find any mistakes or have any suggestions I’m definitely open to PRs or a message on Twitter :)

Until next time,
Tony