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
$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:
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
return [ 'route' => [ 'prefix' => 'api/blog', 'middleware' => ['api'], ],];
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.phproutes/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:
->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:
'load_routes' => true,
$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();
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:cacheRoute::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[]