kirschbaum-development/laravel-route-file-macro
Laravel Route File Macro adds a simple Route::file() macro so you can split large route files into smaller, dedicated files and load them on demand—keeping route definitions organized without extra service providers or manual includes.
Install and verify in three commands:
composer require kirschbaum-development/laravel-route-file-macro
php artisan route:list -v > /tmp/routes-before.txt # baseline BEFORE any conversion
There is nothing to publish and nothing to configure — the package is a single service provider (auto-discovered) that registers one macro on the router. Look first at vendor/kirschbaum-development/laravel-route-file-macro/src/: you want to read the provider and the macro closure itself, because the whole package is roughly ten lines and knowing exactly what it does (Route::group($attributes, base_path($path))) is the key to every gotcha below. Confirm the macro name is hardcoded to file and check what it does on a missing path before you trust it.
The first use case is always the same: pick one low-risk file — webhooks is ideal, because it is small, self-contained, and failures are obvious — and convert one block:
// before — routes/web.php
Route::group(['prefix' => 'webhooks', 'middleware' => 'api'], function () {
// 40 lines of webhook routes
});
// after — routes/web.php
Route::file('routes/webhooks.php', ['prefix' => 'webhooks', 'middleware' => 'api']);
// routes/webhooks.php — plain Laravel, no `return`, no imports needed
Route::post('/stripe', [StripeWebhookController::class, 'handle'])->name('stripe');
Route::post('/github', [GithubWebhookController::class, 'handle'])->name('github');
Then diff:
php artisan route:list -v > /tmp/routes-after.txt && diff /tmp/routes-before.txt /tmp/routes-after.txt
Acceptance criterion: empty diff. If middleware, prefix, name, or ordering changed, stop and do not convert anything else. The package's entire value is four lines of typing; it is not worth a single behavioural surprise.
Root file as table of contents. The durable win is that routes/web.php stops being a 400-line scroll and becomes an index. Order the Route::file() calls to read like a sitemap, and keep the ordering deliberate — registration order is the same as if you had inlined the files, so wildcard routes in an early file will still swallow a later file's paths.
// routes/web.php
Route::file('routes/marketing.php');
Route::file('routes/auth.php');
Route::file('routes/dashboard.php', ['middleware' => ['auth', 'verified'], 'as' => 'dashboard.']);
Route::file('routes/admin.php', ['middleware' => ['auth', 'can:admin'], 'prefix' => 'admin', 'as' => 'admin.']);
Everything Route::group() takes, you pass through. Middleware, prefix, as, domain, where, namespace — no translation layer, no package-specific keys. That is the whole mental model; if you find yourself wanting a key that doesn't work, you have found a divergence and should file it.
API versioning is the cleanest fit. routes/api/v2.php under Route::file('routes/api/v2.php', ['prefix' => 'v2']) alongside a frozen v1 file is the pattern that pays for itself immediately, because version files are read-only after release and the group attributes stay visible at the call site.
Modular monolith / per-domain files. For app/Modules/Billing/routes.php, Route::file() keeps the module's routing self-contained without each module having to register its own provider. Be aware this only works if the path is passed as a string relative to base_path() — see gotchas.
Incremental, reversible migration. The macro coexists with Route::group([], base_path(...)) on the same router, so convert one file per PR and leave the rest on the legacy form. Rollback is a one-line revert, not a dependency removal. This is the single most important workflow property: never do a big-bang conversion.
Nesting. Calling Route::file() inside a Route::group() callback lets a "manifest" file pull in siblings under a shared outer group, inheriting its middleware — verify parity before relying on it, since attribute inheritance is the one place a pass-through wrapper can differ from hand-written groups.
CI guardrail. Add to the pipeline, on every build:
php artisan route:cache && php artisan route:list --json | jq 'length' && php artisan route:clear
Assert the route count and spot-check a handful of route names. This catches the two silent failure modes (stale cache, unresolvable path) that no smoke test will catch for you.
Route-cache parity is the only failure with production consequence. The macro resolves paths at registration time, and route:cache changes when and how that registration runs. A path that resolves on cold boot can resolve to nothing from a cached file, producing routes that exist locally and 404 in production. Gate this explicitly: cold-boot route:list -v, route:cache, route:list -v again, route:clear. Diff all three.
Paths are relative to base_path(), not to the calling file. Do not pass __DIR__.'/webhooks.php' and do not use ../ escapes. On containerized deploys, symlinked current/ releases, and Laravel Vapor-style filesystems, a wrong base resolves to a missing file — and the package may skip silently rather than throw. Test path resolution in the deployed layout, not just on your laptop, and prefer a file_exists() assertion in a boot test over hoping.
Check for a collision before you install. If any in-house service provider or other package already defines Route::file, one registration silently wins. Grep for it:
grep -rn "macro('file'\|macro(\"file\"" app/ packages/ vendor/ 2>/dev/null
If you want belt and braces, re-register defensively in AppServiceProvider::boot() behind if (! Route::hasMacro('file')).
BadMethodCallException: Method ... file does not exist almost always means the provider wasn't discovered — a stale bootstrap/cache/config.php from a deploy that predates the composer require, or dont-discover set in composer.json. Clear config and re-check.
Keep route files dumb. Files loaded via Route::file() are required inside a group closure bound to the router, so $router and the Route facade both work — but helper functions, provider-registered bindings, and container resolution are not guaranteed to be ready in every boot path. Register controllers as [Controller::class, 'method'] or string actions; don't resolve services at file scope.
Never point a file at itself. routes/web.php containing Route::file('routes/web.php') is unbounded recursion, not a caught error.
Pin the version. With 5 stars, 0 dependents, and a single maintaining org, use an exact constraint in composer.json and bump deliberately. Reconcile the advertised last release against Packagist before pinning — pin a verified tag, never a branch.
Know your escape hatch, and consider taking it now. The behaviour is reproducible in about ten lines:
// AppServiceProvider::boot()
Route::macro('file', function (string $path, array $attributes = []) {
return Route::group($attributes, base_path($path));
});
If your team's micro-dependency policy is at all strict, write this instead — identical behaviour, no supply-chain exposure, and you skip every gotcha above except path resolution and cache parity. Adopt the package only if you already tolerate small single-purpose MIT dependencies and want the greppable idiom. Either way, write the convention into the style guide in the same PR; an undocumented pattern drifts back to Route::group([], base_path(...)) and the dependency then earns nothing.
Debugging recipe. php artisan route:list -v --path=admin for a converted prefix; php artisan route:list --json | jq '.[].name' to compare name sets before and after; php artisan about to confirm the provider is actually loaded when behaviour looks impossible.
How can I help you explore Laravel packages today?