illuminate/routing
Laravel's standalone routing component maps HTTP requests to closures or controllers, with route groups, middleware, parameters, named routes, model binding, and resourceful routes. Framework-agnostic enough to power the router in any PHP application.
In practice you almost never install illuminate/routing deliberately. It arrives as a transitive dependency of laravel/framework and is wired up by the framework's RoutingServiceProvider during bootstrapping. If you're in a Laravel app, routing is already there — the package is not the thing you learn first, the routes/ directory is.
Where to look first
routes/web.php and routes/api.php — the files your app actually owns. Note that web.php gets the web middleware group (sessions, CSRF, cookies) and api.php gets the api group (throttling, no session). That distinction is applied by RouteServiceProvider, not by the routes themselves.app/Providers/RouteServiceProvider.php (or bootstrap/app.php with the new withRouting() API in Laravel 11+) — this is where route files are loaded, prefixes assigned, and the api prefix/api middleware group are configured.php artisan route:list — your single most useful command. Run it before you write anything else to see what already exists.Illuminate\Routing\Router is the dispatcher and Illuminate\Routing\Route is a single resolved route. Everything else (RouteRegistrar, ResourceRegistrar, RouteCollection) is a helper around those two.First use case
The natural first task is a CRUD endpoint backed by a controller. Define it, confirm it with route:list, then hit it:
// routes/api.php
use App\Http\Controllers\PostController;
Route::apiResource('posts', PostController::class);
php artisan route:list --path=posts
That prints the five generated routes with their verbs, URIs, names, middleware, and controller actions. If the output looks wrong, the problem is almost always in your RouteServiceProvider/bootstrap config, not the package.
Group by shared attributes, not by file
Every day-to-day route file is really a set of attribute groups: prefix, middleware, name prefix, domain, and constraints. Compose them so each concern is stated once.
Route::prefix('admin')
->name('admin.')
->middleware(['auth', 'can:access-admin'])
->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('users', UserController::class);
});
The fluent RouteRegistrar (returned by Route::prefix(), Route::middleware(), etc.) is lazy — it only commits routes when group() is called. Chaining is fine; forgetting ->group() is the classic silent no-op.
Controllers and route-model binding over closures
Closures are fine for prototypes and for routes/web.php one-liners, but they are unserializable and therefore block route:cache (see Gotchas). For anything you'll deploy, use [Controller::class, 'method'] — the modern array syntax is preferred over the legacy string 'Controller@method' because it gives you IDE navigation and static analysis.
Implicit binding is the default and usually the right choice:
Route::get('/posts/{post}', [PostController::class, 'show']);
// PostController::show(Post $post) — resolved by primary key
Add explicit keys only where the domain calls for it:
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
Reserve Route::bind() for cases the convention can't cover (composite lookups, tenant scoping). Put them in a service provider's boot(), not in the routes file, so they're registered regardless of which route file loads.
Scoping nested resources
Use Route::scopeBindings() (or ->scoped()) whenever you nest resources. Without it, {comment} resolves globally and /posts/1/comments/999 happily returns a comment belonging to a different post — a real authorisation bug, not just a 404 nicety.
Route::apiResource('posts.comments', CommentController::class)->scoped();
Middleware ordering and naming
Name your groups once, in the bootstrap/provider, then reference by alias. Middleware does run in the order listed, and that order matters: auth → can: → throttle is meaningfully different from the reverse.
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->append(TrustProxies::class);
$middleware->group('api', [ThrottleRequests::class.':api']);
})
Route constraints
->where() on a single route, ->whereNumber() / ->whereUuid() on a group. Prefer these over regex validation in the controller — they reject the request before it reaches your code, and they're cached with the routes.
Route::get('/orders/{order}', [OrderController::class, 'show'])->whereUuid('order');
Diagnostics during development
php artisan route:list -v shows middleware; --json is handy for scripting. When a route 404s and you can't tell why, check resolution directly rather than guessing:
$route = app('router')->getRoutes()->match(
Illuminate\Http\Request::create('/posts/1', 'GET')
);
And for cache-related weirdness, app('router')->getRoutes()->getByName('posts.show') tells you whether the name exists at all.
Route caching is the #1 source of "it works locally" bugs. php artisan route:cache serialises the compiled route collection; it cannot serialise closures. If any route file (or a package's) uses a closure, the command fails — sometimes loudly, sometimes with a confusing serialization error. During development, never leave a route cache in place; the stale cache will silently serve old routes after you edit a file. Make route:clear part of your muscle memory, and always run route:clear && route:cache in a deploy step, in that order.
Middleware registered after a route does not apply to it. Middleware is captured onto the Route object at definition time. Registering a global middleware or mutating a group later in the boot cycle affects only subsequently defined routes. In practice: define middleware groups and aliases in the service provider/bootstrap, then load route files.
Route order matters more than you'd expect. First match wins. A catch-all like Route::get('/{slug}', ...) placed above your real routes will swallow everything. Put specific routes first, wildcard/catch-all routes last — and use Route::fallback() for the true catch-all rather than a {any} parameter, since fallback is explicitly deprioritised.
Resource routes silently create seven routes, not five. Route::resource() adds create and edit (the two that render forms) on top of the five RESTful actions. If you're exposing a pure API, use apiResource(); if you already used resource() and don't want them, ->except(['create', 'edit']) (or ->only([...])). The route:list output is the quickest way to catch this.
Named route collisions don't throw. Two routes named posts.show will each overwrite the other in the name lookup; route('posts.show') returns whichever was registered last. If URL generation unexpectedly points to the wrong route, suspect a duplicate name — route:list --name=posts is the check.
Trailing slashes and encodings. The router distinguishes /posts and /posts/ in some configurations; be consistent and don't rely on one redirecting to the other. Also use route() / url() helpers to generate URLs rather than string-concatenating — that's where encoding and the configured base URL get handled correctly.
Model binding failures are 404s, not 403s — and that's occasionally wrong. Implicit binding returns 404 when the model is missing, which is usually right but leaks less than you'd think about resource existence. If you need authorisation semantics, bind the model then use a policy or authorize() in the controller; don't try to make the binding itself return 403.
{param} vs {param?}. Optional parameters must be trailing, and their controller arguments must have defaults. A non-trailing optional segment won't do what you expect.
Version coupling across illuminate/*. Every illuminate/* package in composer.json must be on the same major. Bumping illuminate/http alone produces bizarre routing behaviour (missing ResponseFactory bindings, UrlGenerator mismatches) rather than a clean conflict. Never pin these individually in a Laravel app — let laravel/framework own them.
Debugging container errors. If you see "Target [Illuminate\Contracts\Routing\UrlGenerator] is not instantiable" or similar, the router is fine but a binding is missing — meaning the service provider didn't boot, usually because you're outside a normal Laravel request lifecycle (queued job, custom entry point, test that bypassed TestCase). Fix the bootstrapping, not the route.
Extension points worth knowing. Route::macro() for custom fluent DSL (register in a provider's boot()); $router->matched(fn($route) => ...) and Route::matched() for per-request hooks after resolution; Route::pattern('id', '[0-9]+') in RouteServiceProvider::boot() for global constraints. All three are stable and rarely break across minor versions.
Tests: don't instantiate Router by hand. Use Laravel's Tests\TestCase, which gives you the full container. Prefer $this->get('/path')->assertOk() for behaviour and Route::has('posts.show') when you specifically want to assert registration. If a route test passes alone but fails in the suite, it's nearly always a cached-route or container-state leak — php artisan route:clear and RefreshDatabase resolve it.
How can I help you explore Laravel packages today?