Oh Dear is the all-in-one monitoring tool for your entire website. We monitor uptime, SSL certificates, broken links, scheduled tasks and more. You'll get a notifications for us when something's wrong. All that paired with a developer friendly API and kick-ass documentation. O, and you'll also be able to create a public status page under a minute. Start monitoring using our free trial now.

A package to automatically discover routes in a Laravel app

Original – by Freek Van der Herten – 9 minute read

I'm proud to announce that our team has released a new package called spatie/laravel-route-discovery. This package can be used to automatically discover and register routes by looking at your controllers and views. Of course, you can also still register routes like you're used to in a routes file.

In this blog post, I'd like to tell you all about this package.

Why discover routes automatically?

When I started using Laravel, one of the things I immediately liked was the dedicated routes file. Compared to other frameworks at the time, being able to just declare a route and seeing them all in one place was very convenient. I'm sure that many of you reading this, feel the same about registering routes in Laravel.

Because other frameworks at the time relied solely on discovering routes without a convenience such as a routes file, or had bad DX to do so, route discovery got a bad reputation in the heads of many developers.

But you don't have to choose between a regular routes file and route discovery. If you think of route discovery as an optional feature that works in tandem with a regular routes file, you'll probably can form a more nuanced opinion about route discovery.

We've designed our route discovery package in a way that you could use it for small parts of your app. Only want to discover routes or views in a certain directory? No problem! Want to give a certain discover route some extra middleware? Easy! Should a discovered route have a very specific route name? Can do!

This paves the way for a couple of interesting scenarios. You could for instance, use regular routes for a public facing site of your app, but discover routes automatically for your admin section, where SEO is less important.

Installing the package

You can install the package using composer. No surprises there.

composer require spatie/laravel-route-discovery

Discovering routes for controllers

To start discovering routes for controllers, simply put this code in your routes file.

// anywhere you want, but typically in a routes file

Discover::controllers()->in(app_path('Http/Controllers/Discovery'));

// all of your other routes...
// ...

"Discover controller in...": Don't you love it when a piece of code is also valid English?

You'll notice that in code above I've passed the Http/Controllers/Discovery directory. Using a dedicated Discovery directory can be a way to make it a bit more explicit that the controllers in this directory will be discovered. Of course, you can pass any directory you like.

Of course, all of the discovered routes will also appear whenever you run php artisan route:list.

If you want to prefix the URLs of all discovered controllers you can put that Discover call in a route group.

// anywhere you want, but typically in a routes file

Route::prefix('my-discovered-routes')->group(function() {
   Discover::controllers()->in(app_path('Http/Controllers/Discovery'));
});

You can use a route group for anything you'd like to add to the discovered route, such as adding middleware.

Let's now take a look at how the package will determine the URLs for the controllers in a directory. All further examples assume that you've registered to discover routes for all methods of controller in the app_path('Http/Controllers') directory.

For this controller, the /news/my-method route will be registered.

namespace App\Http\Controllers;

class NewsController
{
    public function myMethod() { /* ... */ }
}

Of course, multiple methods in a controller will result in multiple routes being registered.

For this controller, /news/my-method and /news/my-other-method routes will be registered.

namespace App\Http\Controllers;

class NewsController
{
    public function myMethod() { /* ... */ }
    public function myOtherMethod() { /* ... */ }
}

When a method is named index or __invoke, the method name is not used when registering a route.

For this controller, the /news route will be registered.

namespace App\Http\Controllers;

class NewsController
{
    public function index() { /* ... */ }
}

When a controller is in a sub-namespace, the sub-namespace names will be used when generating the URL.

For this controller, the /nested/news route will be registered.

namespace App\Http\Controllers\Nested;

class NewsController
{
    public function index() { /* ... */ }
}

HTTP verbs

By default, all registered routes are GET routes.

There are a couple of method names that will result in another HTTP verb.

  • store: POST
  • update: PUT and PATCH
  • destroy and delete: DELETE

You can customize the verb to be used by adding a cool Route attribute to a method and pass a value to the method argument. If you're using an IDE, you'll notice that you'll get autocompletion on the parameters you can pass to the Route attribute.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\Route;

class NewsController
{
    #[Route(method: 'post')]
    public function myMethod() { /* ... */ }
}

Customising the URL

If you're not happy with the URL that the package generates, you can override it. The last segment of the generated URL can be overriden by using adding a Route attribute to your method and passing a value to the uri parameter.

For this controller, the /news/alternative-uri route will be registered instead of /news/my-method.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\Route;

class NewsController
{
    #[Route(uri: 'alternative-uri')]
    public function myMethod() { /* ... */ }
}

If you want override the whole URL, pass a value to the fullUri method.

For this controller, the /alternative-uri route will be registered instead of /news/my-method.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\Route;

class NewsController
{
    #[Route(fullUri: 'alternative-uri')]
    public function myMethod() { /* ... */ }
}

What about route names?

By default, the package will automatically add route names for each route that is registered. For this we'll also use the controller name and the method name.

For a NewsController with a method myMethod, the route name will be news.my-method. If that controller in a sub namespace, for example App\Http\Controllers\Nested\NewsController, the route name will become nested.news.my-method.

I think it's really convenient that you don't have to come up with route names yourself.

Of course, you can customize the route name that will be added. Just add a Route attribute and pass a string to the name argument.

For the controller below, the discovered route will have the name special-name.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\Route;

class NewsController
{
    #[Route(name: 'special-name')]
    public function specialMethod() { /* ... */ }
}

Adding middleware

You can apply middleware to a route by... you guessed it... adding a Route attribute and pass a middleware class to the middleware argument.

namespace App\Http\Controllers;

use Illuminate\Routing\Middleware\ValidateSignature;
use Spatie\RouteDiscovery\Attributes\Route;

class DownloadController
{
    #[Route(middleware: ValidateSignature::class)]
    public function download() { /* ... */ }
}

To apply a middleware on all methods of a controller, use the Route attribute at the class level. In the example below, the middleware will be applied on both the routes of both download and otherMethod.

namespace App\Http\Controllers;

use Illuminate\Routing\Middleware\ValidateSignature;
use Spatie\RouteDiscovery\Attributes\Route;

#[Route(middleware: ValidateSignature::class)]
class DownloadController
{
    public function download() { /* ... */ }
    
    public function otherMethod() { /* ... */ }
}

Instead of a string, you can also pass an array with middleware to the middleware argument of the Route attribute.

#[Route(middleware: [ValidateSignature::class, AnotherMiddleware::class])]

Preventing routes from being discovered

You can prevent a certain controller from being discovered by using the DoNotDiscover attribute.

For this controller, only a route for the anotherMethod will be registered.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\DoNotDiscover;

class UsersController
{
    #[DoNotDiscover]
    public function myMethod() { /* ... */}
    
    public function anotherMethod() { /* ... */}
}

You can also prevent an entire controller from being discovered by adding the DoNotDiscover attribute on the class level.

For this controller, not a single route will be registered.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\DoNotDiscover;

#[DoNotDiscover]
class UsersController
{
    public function myMethod() { /* ... */}
    
    public function anotherMethod() { /* ... */}
}

Discovering routes for views

In addition to discovering routes for controllers, the package can also discover routes for a directory that contains Blade views.

In the discover_view_in_directory key of the route-discovery config file, you can specify a directory that contains views. In this example we'll register a route

// config/route-discovery.php

// ...

/*
 * Routes will be registered for all views found in these directories.
 * The key of an item will be used as the prefix of the uri.
 */
'discover_views_in_directory' => [
    'docs' => resource_path('views/docs'),
],

// ..

Alternatively, you could also do this via the routes file. In this example we'll register a route for each file in the docs directory.

Discover::views()->in(resource_path('views/docs'));

If you want to have all routes prefix, you can use a route group.

Route::prefix('docs')->group(function() {
   Discover::views()->in(resource_path('views/docs'));
})

Image that views/docs contains these Blade views...

  • index.blade.php
  • pageA.blade.php
  • pageB.blade.php
  • nested/index.blade.php
  • nested/pageC.blade.php

... then these routes will be registered:

  • /docs --> index.blade.php
  • /docs/page-a --> pageA.blade.php
  • /docs/page-b --> pageB.blade.php
  • /docs/nested --> nested/index.blade.php
  • /docs/nested/page-c --> nested/pageC.blade.php

The registered routes will also be named automatically. The route name will be generated by replacing the / with .. So there routes will have these names:

  • /docs --> docs
  • /docs/page-a --> docs.page-a
  • /docs/page-b --> docs.page-b
  • /docs/nested --> docs.nested
  • /docs/nested/page-c --> docs.nested.page-c

A small source dive

Let's do a short source dive, so you know the broad strokes of how the package works internally.

When you call Discover::controllers()->in(app_path('Http/Controllers/Discovery')); then eventually the registerDirectory method in the RouteRegistrar class will get called.

In that function we will determine which routes should be added for all controllers in a directory. It won't immediately register those routes but convert them to a PendingRoutes.

public function registerDirectory(string $directory): void
{
    $this->registeringDirectory = $directory;

    $pendingRoutes = $this->convertToPendingRoutes($directory);

    $pendingRoutes = $this->transformPendingRoutes($pendingRoutes);

    $this->registerRoutes($pendingRoutes);
}

A pending route is a value object like class that contains all information (such as URL, action, etc...) that we need to register a route. Before actually registering real routes for PendingRoutes, they is passed to transformPendingRoutes.

In that function the collection will be given to the various PendingRouteTransformers that the package ships with. You can see them in the Config class.

<?php

namespace Spatie\RouteDiscovery;

use Spatie\RouteDiscovery\PendingRouteTransformers\AddControllerUriToActions;
use Spatie\RouteDiscovery\PendingRouteTransformers\AddDefaultRouteName;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleDomainAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleDoNotDiscoverAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleFullUriAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleHttpMethodsAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleMiddlewareAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleRouteNameAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleUriAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleUrisOfNestedControllers;
use Spatie\RouteDiscovery\PendingRouteTransformers\HandleWheresAttribute;
use Spatie\RouteDiscovery\PendingRouteTransformers\MoveRoutesStartingWithParametersLast;

class Config
{
    /**
     * @return array<class-string>
     */
    public static function defaultRouteTransformers(): array
    {
        return [
            HandleDoNotDiscoverAttribute::class,
            AddControllerUriToActions::class,
            HandleUrisOfNestedControllers::class,
            HandleRouteNameAttribute::class,
            HandleMiddlewareAttribute::class,
            HandleHttpMethodsAttribute::class,
            HandleUriAttribute::class,
            HandleFullUriAttribute::class,
            HandleWheresAttribute::class,
            AddDefaultRouteName::class,
            HandleDomainAttribute::class,
            MoveRoutesStartingWithParametersLast::class,
        ];
    }
}

Each of those transformers is responsible for a tiny manipulation. Let's explain the first one HandleDoNotDiscoverAttribute. The package has an attribute to not discover a certain controller or method. This is how you use it on a controller.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\DoNotDiscover;

#[DoNotDiscover]
class UsersController
{
    public function myMethod() { /* ... */}
    
    public function anotherMethod() { /* ... */}
}

And here's how to use it on a method.

namespace App\Http\Controllers;

use Spatie\RouteDiscovery\Attributes\DoNotDiscover;

class UsersController
{
    #[DoNotDiscover]
    public function myMethod() { /* ... */}
    
    public function anotherMethod() { /* ... */}
}

Let's now take a look at the code that HandleDoNotDiscoverAttribute class. What happens here in essence, is that every route and action with the DoNotDiscover attribute is removed from the collection.

class HandleDoNotDiscoverAttribute implements PendingRouteTransformer
{
    /**
     * @param Collection<PendingRoute> $pendingRoutes
     *
     * @return Collection<PendingRoute>
     */
    public function transform(Collection $pendingRoutes): Collection
    {
        return $pendingRoutes
            ->reject(fn (PendingRoute $pendingRoute) => $pendingRoute->getAttribute(DoNotDiscover::class))
            ->each(function (PendingRoute $pendingRoute) {
                $pendingRoute->actions = $pendingRoute
                    ->actions
                    ->reject(fn (PendingRouteAction $action) => $action->getAttribute(DoNotDiscover::class));
            });
    }
}

All of the other PendingRouteTransformer classes do similar manipulations.

After all manipulations are done, the PendingRoutes are registered as regular routes inside of the registerRoutes function of the RouteRegistrar class.

protected function registerRoutes(Collection $pendingRoutes): void
{
    $pendingRoutes->each(function (PendingRoute $pendingRoute) {
        $pendingRoute->actions->each(function (PendingRouteAction $action) {
            $route = $this->router->addRoute($action->methods, $action->uri, $action->action());

            $route->middleware($action->middleware);

            $route->name($action->name);

            if (count($action->wheres)) {
                $route->setWheres($action->wheres);
            }

            if ($action->domain) {
                $route->domain($action->domain);
            }
        });
    });
}

In closing

I hope that after reading this blogpost, you've ... 🥁 discovered that you don't have to choose between a having a routes file and automatically discovered routes. You can just use the best parts of both. In your app you use a routes file like you're used to, but have a routes for a subdirectory of controllers or views be auto-discovered.

I'm not trying to convince you that you should automatically discover routes. In the majority of cases, using a routes file is the best way to go. But now you have route discovery as an option that you could consider.

The package has a few more tricks, such as discovery via a config file, where constraints, and much more. Head over to the documentation to ... learn about all the options not mentioned in this blog post. Be sure to also check out all of the packages our team has released previously.

Want to support our efforts, purchase one of our paid products, or sponsor us on GitHub.

Stay up to date with all things Laravel, PHP, and JavaScript.

You can follow me on these platforms:

On all these platforms, regularly share programming tips, and what I myself have learned in ongoing projects.

Every month I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

What are your thoughts on "A package to automatically discover routes in a Laravel app"?

Comments powered by Laravel Comments
Want to join the conversation? Log in or create an account to post a comment.