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();

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', [...]), or where('id', '[0-9]+'). Route::pattern('id', '[0-9]+') in a service provider applies one everywhere. /years/abc matched 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/1 does 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');
});

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

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

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.

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() is null for 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’s controller key. 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() used APP_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

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

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 $namespace property of RouteServiceProvider (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 became AppServiceProvider::boot() and withRouting() 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,1 syntax still works, but named limiters defined with RateLimiter::for() allow per-user limits, several limits and custom responses.