Routing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with Laravel Folio
1.2. Every route below is in the Bookshelf application, and every status code and response was produced by sending
the request with curl. The hand-built router on
Templating, Routing and Middleware by Hand shows
what this replaces; controllers are on Controllers.
Route files and verbs
Routes live in routes/web.php (session, cookies and CSRF protection through the web middleware group) and, after
php artisan install:api, routes/api.php (stateless, prefixed with /api). bootstrap/app.php loads them, and
withRouting(then: …) can load more files; Bookshelf keeps its catalogue routes in routes/catalogue.php:
<?php
// bootstrap/app.php (excerpt)
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function () {
Illuminate\Support\Facades\Route::middleware('web')->group(base_path('routes/catalogue.php'));
},
)
// ... withMiddleware() and withExceptions() ...
->create();
Source: Laravel — Routing Customization
The router has a method per verb (get, post, put, patch, delete, options), plus match(['get', 'post'],
…) and any(…). A route’s action is a closure or a controller ([BookController::class, 'show']). Two shortcuts
need no action at all: Route::view('/about', 'about', ['year' => 2026]) renders a view, and Route::redirect('/old',
'/new', 301) redirects (Route::permanentRedirect() for 301).
Parameters, constraints and names
The routing part of routes/catalogue.php, followed by what each URL returned:
<?php
// routes/catalogue.php
use App\Enums\Shelf;
use App\Models\Author;
use App\Models\Book;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// Parameters, constraints and optional parameters
Route::get('/years/{year}', fn (int $year) => Book::where('published_year', $year)->pluck('title'))
->whereNumber('year')->name('years.show');
Route::get('/search/{term?}', fn (?string $term = null) => ['term' => $term ?? '(none)'])->name('search');
// Implicit binding by a custom column, and a scoped child binding
Route::get('/isbn/{book:isbn}', fn (Book $book) => $book->only('id', 'title'))->name('books.by-isbn');
Route::get('/authors/{author}/books/{book}', fn (Author $author, Book $book) => "{$book->title} by {$author->name}")
->scopeBindings()
->missing(fn (Request $request) => redirect()->route('authors.books', $request->route('author')))
->name('authors.book');
Route::get('/authors/{author}/books', fn (Author $author) => $author->books()->pluck('title'))->name('authors.books');
// Enum binding: only valid enum values match, everything else is a 404
Route::get('/shelves/{shelf}', fn (Shelf $shelf) => ['shelf' => $shelf->name])->name('shelves.show');
Route::redirect('/catalogue', '/books', 301);
GET /years/1979 200 ["Kindred"]
GET /years/abc 404 (the constraint does not match, so no route matches)
GET /search 200 {"term":"(none)"}
GET /search/butler 200 {"term":"butler"}
GET /isbn/978-0-8070-8305-4 200 {"id":1,"title":"Kindred"}
GET /isbn/000 404
GET /authors/1/books/1 200 Kindred by Octavia E. Butler
GET /authors/2/books/1 302 Location: /authors/2/books (book 1 is not by author 2)
GET /shelves/science-fiction 200 {"shelf":"ScienceFiction"}
GET /shelves/poetry 404
GET /catalogue 301 Location: /books
-
Parameters are passed to the action by name and cast to the declared type (
int $year). An optional parameter ({term?}) needs a default value in the action. -
Constraints restrict what a parameter may contain:
whereNumber(),whereAlpha(),whereAlphaNumeric(),whereUuid(),whereUlid(),whereIn('shelf', [...]), orwhere('id', '[0-9]+').Route::pattern('id', '[0-9]+')in a service provider applies one everywhere./years/abcmatched no route at all, so it fell through to a 404. -
Names (
->name('years.show')) let code generate URLs without hard-coding paths:route('years.show', ['year' => 1979]). Renaming a URL then means changing one route definition.
Route model binding
When a parameter is type-hinted with an Eloquent model, Laravel loads the model for you (implicit binding) and
answers 404 if it does not exist:
-
{book}looks up by primary key;{book:isbn}by another column (getRouteKeyName()on the model changes the default for every route). -
With two bound models,
->scopeBindings()(automatic when a custom key is used for the child) loads the second through the first: the query becomes$author->books()->where('id', 1), so/authors/2/books/1does not find author 2’s "book 1", which belongs to author 1. Without scoping, the URL would return the book, a classic authorization hole in nested resources. -
->missing(fn …)replaces the 404 for a missing model, here with a redirect to the author’s book list. -
A backed enum type-hint (
Shelf $shelf) only matches the enum’s values. -
Soft-deleted models are excluded unless the route uses
->withTrashed().
Explicit binding registers the resolution in a service provider instead: Route::model('book', Book::class) or
Route::bind('book', fn (string $value) => Book::where('isbn', $value)->firstOrFail()). The model’s
resolveRouteBinding() method does the same for every route.
Groups
Groups share attributes between routes: a URL prefix, a route-name prefix (name), middleware, a controller
whose methods the routes name, and a domain for subdomain routing (Route::domain('{account}.bookshelf.test')):
<?php
Route::prefix('admin')->name('admin.')->middleware('throttle:reviews')->group(function () {
Route::get('/reports', fn () => 'reports')->name('reports'); // GET /admin/reports, named admin.reports
});
Route::controller(App\Http\Controllers\ReviewController::class)->group(function () {
Route::get('/reviews/{review}', 'show');
Route::post('/reviews', 'store');
});
Source: Laravel — Route Groups
Rate limiting
Rate limiters are named and defined in AppServiceProvider::boot(), then attached with the throttle: middleware.
A limit can be segmented (by()) per user or per IP, combined with others, or vary by response:
<?php
// app/Providers/AppServiceProvider.php, in boot()
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('reviews', fn (Request $request) => Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()));
$ for i in 1 2 3 4; do curl -s -o /dev/null -D - localhost:8000/admin/reports | grep -i -E '^HTTP|ratelimit-remaining|retry-after'; done
HTTP/1.1 200 OK X-RateLimit-Remaining: 2
HTTP/1.1 200 OK X-RateLimit-Remaining: 1
HTTP/1.1 200 OK X-RateLimit-Remaining: 0
HTTP/1.1 429 Too Many Requests x-ratelimit-remaining: 0 retry-after: 60
Source: Laravel — Rate Limiting
Limit::perSecond(), perMinute(), perHour() and perDay() set the window, ->response(fn …) customises the 429,
and returning an array of limits applies all of them. The counters live in the cache store, so all servers must share
it (Redis or the database). With Redis as the cache, $middleware->throttleWithRedis() in bootstrap/app.php maps
throttle to the faster ThrottleRequestsWithRedis implementation. Login throttling and general rate-limit design are on
Cache and Rate Limiting.
Generating URLs and signed URLs
route() builds URLs for named routes (extra parameters become the query string), url() for paths, and
action() for controller actions. A signed URL carries an HMAC signature of itself, made with APP_KEY, and the
signed middleware rejects any URL whose signature does not match: useful for unsubscribe links, email
verification and one-off downloads that must work without login. The fluent Uri class (Laravel 12) wraps a URL
in an immutable object:
<?php
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Uri;
Route::get('/unsubscribe/{user}', fn (int $user) => "user $user unsubscribed")
->middleware('signed')->name('unsubscribe');
Route::get('/demo/urls', fn () => [
'named' => route('books.by-isbn', ['book' => '978-0-8070-8305-4']),
'relative' => route('years.show', ['year' => 1979], absolute: false),
'with_query' => route('search', ['term' => 'butler', 'page' => 2]),
'signed' => URL::temporarySignedRoute('unsubscribe', now()->addMinutes(30), ['user' => 7]),
'uri' => (string) Uri::route('search', ['term' => 'le guin'])->withQuery(['sort' => 'year']),
]);
$ curl -s localhost:8000/demo/urls
{
"named": "http://localhost:8000/isbn/978-0-8070-8305-4",
"relative": "/years/1979",
"with_query": "http://localhost:8000/search/butler?page=2",
"signed": "http://localhost:8000/unsubscribe/7?expires=1791231561&signature=…",
"uri": "http://localhost:8000/search/le%20guin?sort=year"
}
$ curl -o /dev/null -w '%{http_code}\n' "<the signed URL>" # 200
$ curl -o /dev/null -w '%{http_code}\n' "<the same URL with /unsubscribe/8>" # 403
Source: Laravel — URL Generation, Signed URLs and Fluent URI Objects
Changing the user ID in the signed URL invalidated the signature and returned 403. URL::defaults(['locale' =>
'es']), typically set in a middleware, fills a parameter that every route shares.
Fallback routes and method spoofing
Route::fallback(fn () => …) handles requests that match no other route, for a custom 404 page or response. It must
be the last route registered, and an application can only have one. A detail found while testing: Laravel Folio
registers its own Route::fallback(), so an application fallback route silently disables every Folio page (they
all returned the application’s 404 until the fallback was removed).
HTML forms only send GET and POST. To submit a PUT, PATCH or DELETE from a form, add a hidden _method field
(@method('DELETE') in Blade); Laravel then routes the POST as the spoofed method.
Source: Laravel — Fallback Routes and Form Method Spoofing
Catch-all parameters, soft-deleted bindings and the current route
Three route options come up once an application grows. A parameter matches anything but / unless its constraint
allows it, which is how a route takes a path of any depth. Implicit binding skips soft-deleted models unless the
route chains withTrashed(), which suits a moderation screen. And code that runs inside a request can ask the router
which route matched:
<?php
// routes/web.php
Route::get('/shelves/{path}', fn (string $path) => ['path' => $path, 'segments' => explode('/', $path)])
->where('path', '.*')->name('shelves.browse');
Route::get('/moderation/reviews/{review}', fn (App\Models\Review $review) => ['id' => $review->id, 'trashed' => $review->trashed()])
->middleware('auth')->withTrashed()->name('moderation.reviews.show');
Route::get('/where-am-i/{book}', function (Illuminate\Http\Request $request, App\Models\Book $book) {
return [
'name' => Route::currentRouteName(),
'action' => Route::currentRouteAction(),
'routeIs' => $request->routeIs('where-am-i', 'books.*'),
'parameter' => $request->route('book')->title,
'uri' => $request->route()->uri(),
];
})->name('where-am-i');
<?php
// tests/Feature/RoutingExtrasTest.php
use App\Models\{Book, Review, User};
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('lets the last parameter span slashes', function () {
$this->getJson('/shelves/fiction/science-fiction/1970s')
->assertExactJson(['path' => 'fiction/science-fiction/1970s', 'segments' => ['fiction', 'science-fiction', '1970s']]);
$this->getJson('/shelves/fiction%2Fclassics')->assertJsonPath('path', 'fiction/classics');
expect(route('shelves.browse', ['path' => 'fiction/classics']))->toEndWith('/shelves/fiction/classics');
});
it('binds soft-deleted models only where the route asks for them', function () {
$review = Review::factory()->create();
$review->delete();
$this->actingAs(User::factory()->create());
$this->getJson("/reviews/{$review->id}")->assertNotFound();
$this->getJson("/moderation/reviews/{$review->id}")->assertExactJson(['id' => $review->id, 'trashed' => true]);
});
it('describes the current route', function () {
$book = Book::factory()->create(['title' => 'Kindred']);
$this->getJson("/where-am-i/{$book->id}")->assertExactJson([
'name' => 'where-am-i', 'action' => null, 'routeIs' => true,
'parameter' => 'Kindred', 'uri' => 'where-am-i/{book}',
]);
expect(Route::getRoutes()->getByName('reviews.show')->getActionName())
->toBe('App\\Http\\Controllers\\BookReviewController@show');
});
$ php artisan test tests/Feature/RoutingExtrasTest.php
PASS Tests\Feature\RoutingExtrasTest
✓ it lets the last parameter span slashes 0.51s
✓ it binds soft-deleted models only where the route asks for them 0.06s
✓ it describes the current route 0.02s
Tests: 3 passed (7 assertions)
Two of the assertions had to change after the first run, and both are worth knowing:
-
Route::currentRouteAction()isnullfor a closure route. The documentation shows it returning a string, and it does for a controller action (App\Http\Controllers\BookReviewController@show), because it reads the route’scontrollerkey. Code that logs or authorizes by action name needs a fallback for closures, or should use the route name, which every route that matters should have. -
*
route()usedAPP_URL`'s port* (http://localhost:8000/...), not the test request's host, so the test checks the path only. The generated URL kept the `/inside the parameter unencoded, so it matched the route again.
A parameter can hold slashes only in the last segment of the route, and the encoded %2F arrived decoded as
/. Without withTrashed(), the deleted review was a 404 on the ordinary reviews.show route, which is the safe
default. routeIs() accepts several patterns with *, the check behind navigation that highlights the current section
(@class(['active' => request()->routeIs('books.*')])).
Listing and caching routes
php artisan route:list shows every route with its method, URI, name and action; --path=, --name=, --method=
and --except-vendor filter it, and -v adds the middleware:
$ php artisan route:list --except-vendor
GET|HEAD / .......................................... routes/web.php:6
GET|HEAD admin/reports ....... admin.reports › routes/catalogue.php:29
GET|HEAD authors/{author}/books authors.books › routes/catalogue.php:…
GET|HEAD authors/{author}/books/{book} authors.book › routes/catalogu…
GET|HEAD books .................... books.index › BookController@index
GET|HEAD books/{book} ............... books.show › BookController@show
ANY catalogue ........... Illuminate\Routing › RedirectController
GET|HEAD isbn/{book:isbn} .... books.by-isbn › routes/catalogue.php:17
GET|HEAD search/{term?} ............. search › routes/catalogue.php:14
GET|HEAD shelves/{shelf} ...... shelves.show › routes/catalogue.php:25
...
$ php artisan route:cache
INFO Routes cached successfully.
$ php artisan route:clear
Source: Laravel — Listing Your Routes and Route Caching
route:cache compiles all routes into bootstrap/cache/routes-v7.php, which makes route registration almost free on
each request in production. Closure routes can be cached (they are serialised). Run it on every deployment, and not
in development, where new routes would be ignored until route:clear.
Folio: page-based routing
Laravel Folio turns Blade files into routes by their path, an alternative for content-heavy sites. After composer
require laravel/folio and php artisan folio:install, every file in resources/views/pages is a page, and a file
name in brackets is a parameter, with model binding:
{{-- resources/views/pages/shelf/[Book:isbn].blade.php --}}
<?php
use function Laravel\Folio\name;
name('shelf.book');
?>
<h1>{{ $book->title }}</h1>
$ php artisan folio:list
GET /about ........................................... about.blade.php
GET /shelf/{book:isbn} ...... shelf.book › shelf/[Book:isbn].blade.php
$ curl localhost:8000/shelf/978-0-8070-8305-4
<h1>Kindred</h1>
$ curl -o /dev/null -w '%{http_code}\n' localhost:8000/shelf/000
404
Source: Laravel — Folio
Middleware for pages is configured per path in the FolioServiceProvider that folio:install publishes. Folio suits
mostly static pages and simple views; anything with forms, validation and several actions is clearer with routes and
controllers.
Where older books and tutorials are outdated
-
'BookController@show'strings and the$namespaceproperty ofRouteServiceProvider(Stauffer ch. 3) were replaced by[BookController::class, 'show']in Laravel 8; tooling and refactoring understand the array form. -
RouteServiceProvider::boot()for rate limiters, patterns and route files becameAppServiceProvider::boot()andwithRouting()in Laravel 11. -
Nested resource URLs without scoped bindings return children of the wrong parent; use
scopeBindings()(or a custom key, which scopes automatically). -
The
throttle:60,1syntax still works, but named limiters defined withRateLimiter::for()allow per-user limits, several limits and custom responses.