Skip to content
Package Toolkit
GitHub repository

Routes

Load your package's route files and let consumers publish them for customisation.

On this page 9
public function hasRoutes(
array|string|null $routeFiles = null,
string $directory = '../routes',
): static

Route files are loaded at boot with Laravel's own loadRoutesFrom(), which means they participate in route:cache exactly like an application's routes do.

Loading every route file

src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasRoutes();
acme/blog/
└── routes/
├── web.php
└── api.php

Naming files explicitly

$packager->hasRoutes('api.php');
$packager->hasRoutes(['api.php', 'web.php']);

The order is the load order. It rarely matters, but when two files register the same URI the first one wins, so an explicit list is the way to make that deterministic.

Discovery picks up every file

hasRoutes() with no arguments loads everything in routes/ — including a channels.php that is meant for broadcast channels. A channel authorization callback loaded as a route file is registered inside a route group, which is the wrong destination for it. Either name your route files explicitly, or keep channels in their own directory.

Writing the route file

The toolkit loads the file and stops there. It does not apply a prefix, a middleware group or a name prefix on your behalf — those belong inside the file, where a reader of your package can see them:

routes/api.phpphp
use Acme\Blog\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
 
Route::middleware(['api', 'throttle:60,1'])
->prefix('api/blog')
->name('blog.api.')
->group(function () {
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
});

That gives /api/blog/posts and the route name blog.api.posts.index. Prefixing route names with your short name is worth doing: route names are a flat global namespace, and posts.index is a collision waiting to happen.

Making the prefix configurable

config/blog.phpphp
return [
'route' => [
'prefix' => 'api/blog',
'middleware' => ['api'],
],
];
routes/api.phpphp
Route::middleware(config('blog.route.middleware'))
->prefix(config('blog.route.prefix'))
->name('blog.')
->group(function () {
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');
});

This works because config is merged during register() and routes are loaded during boot() — your defaults are in place by the time the file runs, whether or not the consumer published it.

Publishing

php artisan vendor:publish --tag=blog::routes

Files land in routes/vendor/{short-name}/:

routes/vendor/blog/api.php
routes/vendor/blog/web.php

Publishing a route file is a copy, not a takeover. Your package keeps loading its own file from vendor/; the published copy is inert until the consumer loads it themselves:

bootstrap/app.phpphp
->withRouting(
web: __DIR__.'/../routes/web.php',
then: function () {
require base_path('routes/vendor/blog/api.php');
},
)

Which means a consumer who publishes and edits, without doing that, ends up with two copies of every route — yours from the package, theirs from the published file, both registered. Say so in your readme, or offer a config flag:

config/blog.phpphp
'load_routes' => true,
src/BlogServiceProvider.phpphp
$packager
->name('Blog')
->hasConfig()
->registeredPackage(function (Packager $packager) {
if (config('blog.load_routes', true)) {
$packager->hasRoutes();
}
});

Route model binding

Register bindings from the booted lifecycle hook, which runs after routes are loaded:

use Illuminate\Support\Facades\Route;
 
$packager->bootedPackage(function () {
Route::bind('post', function (string $value) {
return Post::where('slug', $value)->firstOrFail();
});
});

Controllers and middleware

Controllers are just classes in your package — nothing to declare. Middleware referenced by alias in a route file must be registered first; see Middleware:

$packager
->name('Blog')
->hasMiddlewareAliases(['blog.author' => EnsureUserIsAuthor::class])
->hasRoutes();
routes/api.phpphp
Route::middleware(['api', 'blog.author'])->group(/* … */);

bootRoutes() actually runs before bootMiddleware() in the toolkit's boot sequence, which sounds like a problem and is not: Laravel resolves a middleware alias when a request is dispatched, not when the route is declared. The alias only has to exist by the time a request arrives.

Route caching

Because loading goes through loadRoutesFrom(), php artisan route:cache covers your package's routes. The usual constraint applies to your files as much as an application's: no closures.

// ✗ breaks route:cache
Route::get('/health', fn () => response()->json(['ok' => true]));
 
// ✓
Route::get('/health', HealthController::class);

An invokable single-action controller is the cheap fix.

Introspection

$packager->isRoutable(); // bool
$packager->routeFiles(); // Support\SplFileInfo[]