A package to automatically discover routes in a Laravel app
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
andPATCH
-
destroy
anddelete
: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 PendingRoute
s.
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 PendingRoute
s, 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.
What are your thoughts on "A package to automatically discover routes in a Laravel app"?