Laravel routing looks simple until the day you ask "why does this route return a CSRF error" or "why did my API route just lose the user's session." Both questions trace back to the same thing: web.php and api.php aren't just two files that happen to hold different URLs — they run through different middleware stacks with different assumptions baked in.
This is Part 1 of a series on Laravel routing in production. It covers how a route actually gets matched and handled, what changed in the file structure as of Laravel 11, the real difference between web.php and api.php, and Route::resource — the shortcut that quietly defines seven routes at once.
The simplest route: a closure
Every Laravel route maps an HTTP verb and a URI to something that handles the request:
use Illuminate\Support\Facades\Route;
Route::get('/welcome', function () {
return 'Hello, world';
});
A closure route is fine for a quick redirect or a static page, but it has no name by default, can't be cached the way controller routes can (closures can't be serialized into Laravel's compiled route cache — more on that in Part 4), and tends to accumulate logic that belongs in a controller. For anything beyond a one-liner, route to a controller method instead:
use App\Http\Controllers\PostController;
Route::get('/posts/{post}', [PostController::class, 'show']);
Where routes live: the Laravel 11+ structure
If you're on Laravel 11, 12, or 13, route configuration doesn't live where older tutorials say it does. Versions before Laravel 11 registered route files inside app/Providers/RouteServiceProvider.php. That file is gone. Route files are now wired up directly in bootstrap/app.php:
// bootstrap/app.php
use Illuminate\Foundation\Application;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function ($middleware) {
//
})
->withExceptions(function ($exceptions) {
//
})->create();
Notice there's no api: parameter above. That's not an omission — routes/api.php does not exist in a fresh Laravel 11+ install. If your project needs an API, you add it explicitly:
php artisan install:api
This scaffolds routes/api.php, wires api: __DIR__.'/../routes/api.php' into withRouting() for you, and installs Laravel Sanctum for token authentication. Every URI registered in api.php is automatically prefixed with /api unless you override it with the apiPrefix argument to withRouting().
If you're maintaining a Laravel 10 or earlier app, route files are still registered in RouteServiceProvider::boot() — the concept is identical, just the location moved.
web.php vs. api.php: it's the middleware, not the folder
The actual difference between the two files isn't where they live, it's which middleware group wraps every route inside them.
web.php routes run through the web middleware group: session handling, CSRF token verification, cookie encryption. This is what makes @csrf in a Blade form work, and what lets Auth::user() remember who's logged in between requests.
api.php routes run through the api middleware group instead, which by default does not include session or CSRF middleware. This is deliberate: a stateless API shouldn't depend on cookies to know who's calling it, and a typical third-party API client has no CSRF token to send in the first place.
This is the exact bug a lot of developers hit once: calling session() or relying on CSRF protection from inside an api.php route and getting a "session store not set on request" error, because the middleware that boots the session was never in that request's stack to begin with. The fix isn't to bolt StartSession back onto api.php — that defeats the point of a stateless API. If the API needs to serve your own frontend using cookie-based sessions (an SPA on the same domain, for example), the correct tool is Sanctum's EnsureFrontendRequestsAreStateful middleware, added deliberately to the api group so only requests from your recognized frontend domains get session/CSRF treatment, while everyone else stays stateless.
// config/sanctum.php (added by `install:api`)
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
Route::resource: one line, seven routes
Most CRUD controllers follow the same shape: list, show a create form, store, show one, show an edit form, update, destroy. Rather than writing all seven routes out, Route::resource generates them from a controller name:
use App\Http\Controllers\PostController;
Route::resource('posts', PostController::class);
That single line is equivalent to:
| Verb | URI | Controller method | Route name |
|---|---|---|---|
| GET | /posts | index | posts.index |
| GET | /posts/create | create | posts.create |
| POST | /posts | store | posts.store |
| GET | /posts/{post} | show | posts.show |
| GET | /posts/{post}/edit | edit | posts.edit |
| PUT/PATCH | /posts/{post} | update | posts.update |
| DELETE | /posts/{post} | destroy | posts.destroy |
Run php artisan route:list any time to see exactly what a resource route expanded into — it's the fastest way to confirm what's actually registered instead of guessing from the one-liner.
If your controller doesn't implement every action, restrict which ones get registered instead of leaving dead routes pointing at missing methods:
// Only index and show — no create/edit/update/destroy routes at all.
Route::resource('posts', PostController::class)->only(['index', 'show']);
// Everything except destroy.
Route::resource('posts', PostController::class)->except(['destroy']);
For an API that never serves the create/edit HTML forms, apiResource registers only the five JSON-relevant routes automatically:
Route::apiResource('posts', PostController::class);
The {post} parameter is already a model, not an ID
Notice the show, edit, update, and destroy routes above take {post}, not {id}. If the controller method type-hints a Post model, Laravel resolves the URL segment to a model instance automatically:
use App\Models\Post;
public function show(Post $post)
{
return view('posts.show', ['post' => $post]);
}
Laravel takes the {post} value from the URL, looks up a Post by its route key (the primary key, by default), and injects the fully-loaded model — or throws a 404 automatically if no matching row exists. This is route model binding, and it's the reason resource controllers rarely contain a manual Post::findOrFail($id) line. Part 2 of this series goes deeper: custom route keys, explicit binding, scoping binding to a parent resource, and handling soft-deleted models.
Always name your routes
A route without an explicit name still has an implicit path, but referencing it by URL string in a redirect or a Blade link means every rename of that URL becomes a find-and-replace across the app. Naming it once avoids that:
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
// In a controller or anywhere else in the app:
return redirect()->route('posts.show', $post);
// In a Blade view:
<a href="{{ route('posts.show', $post) }}">Read more</a>
Route::resource names every one of its seven routes automatically, in the posts.index, posts.show pattern from the table above, which is one more reason to prefer it over hand-writing the same seven routes individually.
Quick reference
| Question | Answer |
|---|---|
| Where are routes registered (Laravel 11+)? | bootstrap/app.php, via withRouting() |
| Where were they registered (Laravel 10 and earlier)? | app/Providers/RouteServiceProvider.php |
| Does api.php exist by default? | No — run php artisan install:api to add it |
| Does web.php have sessions/CSRF? | Yes, via the web middleware group |
| Does api.php have sessions/CSRF? | No, by design — add Sanctum's EnsureFrontendRequestsAreStateful if your own frontend needs it |
| Fastest way to see what a route macro expands to | php artisan route:list |
What's next
This covered how routes are structured and matched. Part 2 goes deeper into the {post} parameter from the resource table above: implicit vs. explicit route model binding, custom route keys, scoping bindings for nested resources, and what happens when the bound model has been soft-deleted.
Originally published on DEV Talk.
Top comments (0)