Lesson 111·M14 Routing & Request Handling
Routing
OpenLearning Objectives
By the end of this lesson you should be able to:
- Write a route for any HTTP method and read
php artisan route:listlike a table - Define required, optional and constrained parameters — and their ordering rules
- Explain exactly how
{user}plus aUsertype-hint becomesselect * from users where id = ? - Build a prefixed, named, middleware-wrapped route group from memory
- Say why
route:cacherefuses closures — and what you register instead - List the seven routes a single
Route::resource()line registers
One-Line Definition
Routing is the map from URL to controller: Route::get('/users', [UserController::class, 'index']) says "when a GET request arrives at /users, run this action."
Mental Model
Think of routes/web.php as a switchboard. The router answers with the first line whose method and path pattern both match the incoming request — nothing else. There is no "best match" and no longest-prefix logic: the first matching row wins, in file order.
routes/web.php (the switchboard)
GET /users → UserController@index
POST /users → UserController@store
GET /users/{user} → UserController@show ← {user} is a slot
GET /users/{user}/edit → UserController@edit
A request for GET /users/42 drops into the {user} slot and lands on show(42).
Every URL in the app is one row in a table like this. The job of the router is to pick the row and hand the request to its handler.
Visual Flow
GET /users/42?sort=name
│
▼
Router matches method + path pattern
│ (routes/web.php — first match wins)
▼
{user} = "42" ──► implicit model binding
│ User::findOrFail(42)
▼
Controller action runs: UserController@show(User $user)
│
▼
Response (view / JSON / redirect) leaves the app
The URL is parsed once, matched against the table once, and the rest is parameter passing.
How It Works
Laravel loads routes/web.php and routes/api.php during bootstrapping. Each file is a list of registrations, and the order in the file is the matching order. Two registrations for the same path — the first one wins, silently:
// routes/web.php
use App\Http\Controllers\UserController;
Route::get('/', function () {
return view('welcome');
});
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::put('/users/{user}', [UserController::class, 'update']);
Route::patch('/users/{user}', [UserController::class, 'update']);
Route::delete('/users/{user}', [UserController::class, 'destroy']);
This is the table the router actually works from:
$ php artisan route:list
GET|HEAD / ............................. closure
GET|HEAD /users ............................. App\Http\Controllers\UserController@index
POST /users ............................. App\Http\Controllers\UserController@store
PUT /users/{user} ............................. App\Http\Controllers\UserController@update
PATCH /users/{user} ............................. App\Http\Controllers\UserController@update
DELETE /users/{user} ............................. App\Http\Controllers\UserController@destroy
Tip
php artisan route:list is the router's own table. Interviewers love it because it reveals both what's registered and what isn't — run it before claiming "the route should work".
Parameters and optional parameters
A {name} is a slot that matches one path segment and passes its value to the handler. Adding ? makes the slot optional — and an optional slot must be the last segment:
Route::get('/search/{term?}', function (string $term = 'all') {
return "Searching for: {$term}";
});
GET /search → "Searching for: all"
GET /search/laravel → "Searching for: laravel"
Model binding
The interesting case is {user} with a User type-hint. Laravel sees the variable name matches the segment name and the type-hint names a model, so it resolves the segment to a model instead of a raw string:
use App\Models\User;
Route::get('/users/{user}', function (User $user) {
return "Showing {$user->name}";
})->whereNumber('user');
GET /users/42
1. router extracts the {user} segment → "42"
2. the User type-hint says "this is a model binding"
3. the variable name {user} matches $user → User::findOrFail(42)
SQL: select * from users where id = 42 limit 1
GET /users/999 → 404 (ModelNotFoundException)
When the lookup isn't by primary key, bind explicitly:
// app/Providers/AppServiceProvider.php
use App\Models\Post;
public function boot(): void
{
// force the model for a segment, by name
Route::model('post', Post::class);
// or fully custom resolution — here by slug instead of id
Route::bind('post', function (string $value) {
return Post::where('slug', $value)->firstOrFail();
});
}
Named routes, groups, and the fallback
Route::get('/users/{user}', [UserController::class, 'show'])->name('users.show');
// anywhere else in the app:
$url = route('users.show', ['user' => 42]); // http://localhost/users/42
redirect()->route('users.show', ['user' => 42]);
Route::prefix('admin')->middleware('auth')->name('admin.')->group(function () {
Route::get('users', [AdminUserController::class, 'index'])->name('users');
Route::get('reports', [ReportController::class, 'index'])->name('reports');
});
Route::fallback(function () {
return response()->json(['message' => 'Not Found'], 404);
});
GET /admin/users → gated by auth, route name: admin.users
GET /admin/reports → gated by auth, route name: admin.reports
GET /no/such/page → 404 {"message":"Not Found"} (the fallback)Real Project Usage
| Pattern | Example | Serves |
|---|---|---|
| Static | Route::get('/about', ...) | /about |
| Parameter | Route::get('/users/{user}', ...) | /users/42 |
| Optional param | Route::get('/search/{term?}', ...) | /search and /search/laravel |
| Named | ->name('users.show') | route('users.show', ...) |
| Group | prefix('admin')->middleware('auth') | /admin/*, auth-gated |
| Resource | Route::resource('posts', PostController::class) | all 7 post routes |
| API | routes/api.php | /api/*, stateless |
This table is the whole lesson in miniature — everything else is one of these rows, detailed.
Interview Explanation
The router maps an HTTP method plus a URL pattern to a handler, and the first matching row wins. Parameters are
{name}slots in the pattern; a?makes one optional. A{user}slot matched against aUsertype-hint triggers implicit model binding — Laravel fetches the model by id and injects it. Named routes let me generate URLs and redirects from a name instead of a hard-coded path. Groups apply a prefix, middleware and a name prefix to many routes at once, andRoute::resource()registers the standard seven CRUD routes in one line.
That's the 30-second answer. Now the parts people get wrong.
Senior-Level Insights
- Route order is a silent contract. Because the first match wins, a
{user}route registered before a static/users/newswallows it. Senior teams keep specific routes above dynamic ones and reviewroute:listin PRs. - Closures are a cache killer.
php artisan route:cacheserializes the route table to a file — and a Closure cannot be serialized. Long-lived apps register controllers or invokable classes precisely so routes can be cached:
$ php artisan route:cache $ php artisan route:cache (closure route present)
Route cache cleared! Unable to prepare route [/] for serialization.
Routes cached successfully! Uses Closure.
- Routes are configuration, not code. A route file with business logic is a smell — it's a table. An
ifinside a route means the decision belongs in middleware (Lesson 112) or a controller (Lesson 113). - Names are the API of your URLs.
route('users.show', $user)survives a URL change; a hard-coded/users/42in a view doesn't. Naming discipline is what makes redirects and links refactorable. routes/api.phpis stateless by design. It carries no session middleware and is throttled viathrottle:api. That is the contract your SPA or mobile client relies on — never add session logic there.
Common Mistakes
- Optional parameter in the middle:
Route::get('/users/{user?}/posts', ...)— an optional slot must be last, or routing can't tell where the segment ends. - Ordering a dynamic route before a specific one — the specific route silently never matches.
- Binding by type alone. Implicit binding keys off the variable name matching the
{...}slot, not just the type:{user}must pair with$user. - Registering in the wrong file —
web.phpvsapi.phpchanges middleware, sessions and prefix. A 419 (CSRF) on an endpoint usually means it belongs inapi.php. - Forgetting
route:clearafter changing routes on a cached deploy — a stale cache serves stale routes.
Best Practices
✅ Keep routes declarative — one line per route, no logic inside
✅ Name routes you link or redirect to (->name('users.show'))
✅ Use groups for a shared prefix + middleware + name prefix
✅ Prefer Route::resource() / apiResource() for CRUD — seven routes, one line, consistent names
✅ Constrain dynamic segments (->whereNumber('user'))
✅ Run php artisan route:cache on deploy and route:clear when the table changes
❌ Don't use closures when the route will be cached — register a controller action
❌ Don't hard-code URLs in views and redirects — use route()
❌ Don't put if or business logic inside a route file
Interview Questions
Q1. What does a route do?
It maps an HTTP method and a URL pattern to a handler, usually a controller action, and the first matching registration wins. Laravel loads
routes/web.phpandroutes/api.phpat boot, so file order is match order.
Q2. How do parameters work?
A
{name}in the pattern is a slot./users/{user}matches/users/42and passes"42"to the handler. Adding?makes it optional —/search/{term?}matches/searchtoo — and an optional parameter must be last.
Q3. What happens with Route::get('/users/{user}', fn (User $user) => ...) — how does {user} become a User?
Implicit route model binding. The variable name
usermatches the{user}segment, and theUsertype-hint tells the router the segment is a model id, so it runsUser::findOrFail($user)—select * from users where id = ?— and injects the model. A missing row means a 404.
Q4. What are named routes for?
A stable handle for a URL.
route('users.show', ['user' => 42])generates the URL and survives URL changes, andredirect()->route(...)uses the same name — so renaming a path never means hunting through views.
Q5. What do route groups give you?
One place to apply a prefix, middleware and a name prefix to many routes:
Route::prefix('admin')->middleware('auth')->name('admin.')->group(...). Every route inside inherits all three —/admin/users, auth-gated, namedadmin.users.
Q6. What is route caching, and why does it break closures?
php artisan route:cacheserializes the compiled route table tobootstrap/cacheso the router doesn't recompile it per request — a measurable production win. Closures can't be serialized, so the command throws. That's why production apps register controller actions and invokable classes instead of closures.
Senior follow-up: How do you register a route that must only match numeric ids?
A constraint:
->whereNumber('user')(or->where('user', '[0-9]+')), or a globalRoute::pattern('id', '[0-9]+')in a service provider so every{id}segment inherits it. A non-matching segment falls through to the next route or a 404.
Follow-up Questions
Can two routes match one URL?
Yes — the first registration wins and the second is dead code. This is the classic "why is my static route not working" cause: a dynamic route above it swallows the URL first.
What's the difference between a required and an optional parameter?
A required one must be present or the route doesn't match. An optional one (
{term?}) matches with or without the segment; the handler reads a default ($term = 'all') when it's absent.
When would you use explicit binding?
When the lookup isn't by primary key —
Route::bind('post', fn ($value) => Post::where('slug', $value)->firstOrFail())for slug URLs, orRoute::model('post', Post::class)to force a model for a differently-named segment.
Comparison Table
routes/web.php | routes/api.php | |
|---|---|---|
| Prefix | / | /api |
| Middleware group | web (session, CSRF) | api (throttle, no session) |
| Built for | browser pages, forms | SPAs, mobile, third parties |
| CSRF | enforced | not enforced |
| Typical handler | view-returning controller | JSON-returning controller |
| Implicit binding | Explicit binding | |
|---|---|---|
| Declared | type-hint + {name} | Route::model() / Route::bind() |
| Lookup | by primary key | anything you write |
| 404 on miss | ModelNotFoundException → 404 | whatever your closure does |
Code Example
One file exercising most of the lesson:
// routes/web.php
use App\Http\Controllers\PostController;
use App\Models\User;
// static
Route::get('/', fn () => view('welcome'))->name('home');
// optional parameter + constraint
Route::get('/search/{term?}', function (string $term = 'all') {
return "Searching for: {$term}";
})->whereAlpha('term');
// implicit model binding — {user} matches $user, the User type-hint drives the query
Route::get('/users/{user}', function (User $user) {
return "Showing {$user->name}";
})->whereNumber('user');
// group: prefix + middleware + name prefix
Route::prefix('admin')->middleware('auth')->name('admin.')->group(function () {
Route::get('users', [AdminUserController::class, 'index'])->name('users');
});
// resource: all 7 CRUD routes in one line
Route::resource('posts', PostController::class);
What the router does with it:
GET /search → "Searching for: all"
GET /search/laravel → "Searching for: laravel"
GET /users/42 → "Showing Ada" (SQL: select * from users where id = 42 limit 1)
GET /users/abc → 404 (whereNumber rejected it)
GET /admin/users → AdminUserController@index, gated by auth
GET /posts → PostController@index (resource route)
GET /posts/7/edit → PostController@edit (resource route)Performance Notes
- Route caching is the big win.
route:cacheturns per-request route compilation into a single unserialize — the most effective routing optimization, and it only works when no closures are involved. - Compilation cost is paid once, matching cost per request. The match is a linear scan of the table. A few hundred routes are nothing; thousands with regex constraints start to matter.
- Constraints cost more than plain segments. Regex
->where()runs per match attempt. Use the built-ins (whereNumber,whereAlpha) and keep custom regexes short. route()generation is cheap but not free — in a tight loop, compute the URL once outside the loop.
Debugging Scenarios
| Symptom | Cause | Fix |
|---|---|---|
| "My route returns 404 but it's registered" | stale route cache, or the file isn't loaded | php artisan route:clear; confirm the file is routes/web.php or routes/api.php |
| A static route never matches | a dynamic route above it wins | move the specific route above the dynamic one; verify with route:list |
{user} isn't being bound | variable name ≠ segment name, or no type-hint | rename to match: {user} + User $user |
/users/abc matches when it shouldn't | no constraint | ->whereNumber('user') or a global Route::pattern |
| 419 on an endpoint that should be an API | the route is in web.php (CSRF group) | move it to routes/api.php |
route:cache throws "Uses Closure" | a closure route can't serialize | convert to a controller action or invokable class |
Quick Revision Notes
- A route = HTTP method + URL pattern → handler; first match wins, file order is match order
{name}= required segment;{name?}= optional, must be last- Named routes:
->name('x'); useroute('x', ...)andredirect()->route('x') - Groups:
prefix(),middleware(),name()apply to every route inside - Implicit binding:
{user}+User $user→User::findOrFail($user)→select ... where id = ? - Explicit binding:
Route::model()/Route::bind()for non-id lookups Route::resource('posts', PostController::class)→ index/create/store/show/edit/update/destroyroutes/api.php→/apiprefix, no session, throttledRoute::fallback(...)→ the last-resort 404 handler- Constraints:
->whereNumber(),->whereAlpha(),->where('user', '[0-9]+'), globalRoute::pattern() route:cacheserializes the table; closures break it;route:clearfixes staleness
Cheat Sheet
Route::get('/path', handler)->name('n')->middleware('m')->where('p', 're');
│ │ │ │ │
│ └ closure | [Ctrl::class,'method'] | invokable class
└ get|post|put|patch|delete|match|any|fallback
{user} required segment
{user?} optional (must be last)
{user:slug} scoped binding — resolve by a column
fn (User $u) implicit binding → User::findOrFail(id)
Groups:
Route::prefix('admin')->middleware('auth')->name('admin.')->group(fn () => …);
Resource: Route::resource('posts', PostController::class)
index GET /posts create GET /posts/create
store POST /posts show GET /posts/{post}
edit GET /posts/{post}/edit update PUT|PATCH /posts/{post}
destroy DELETE /posts/{post}
apiResource(...) = resource minus create/edit views
Route::fallback(fn () => abort(404));
php artisan route:list | route:cache | route:clearKey Takeaways
Recap
- A route binds an HTTP method + URL pattern to a handler; the first match wins
- Parameters are
{name}slots; add?for optional (last only),whereNumber/whereAlphato constrain - Implicit model binding: the variable name matches the segment, the type-hint runs the query
- Groups stamp prefix + middleware + name on every inner route
Route::resource()registers the seven CRUD routes in one lineroutes/api.phpis stateless,/api-prefixed and throttledroute:cacheserializes routes — and that's exactly why closures are banned from cached routes
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/7Which row wins when two routes match the same URL, and what decides the order?
Write the route that serves both /search and /search/laravel.
What three facts make {user} resolve to a User model — segment, variable, type-hint?
Write a group that puts every inner route under /admin, gated by auth, named admin.*.
List the seven routes Route::resource('posts', ...) registers.
Why does route:cache throw on a closure route — and what's the fix?
Your API endpoint is returning 419. What's the most likely cause?
What's Next
Lesson 112 — Middleware. The onion layers around your routes: auth, throttling, CORS —
and what $next($request) really does when the request and response pass through each layer.