One Verb, One URI, One Handler: How a Request Becomes a Response
Every page, API endpoint, and form action in a Laravel app starts the same way: a line in routes/web.php that pairs an HTTP verb with a URI and says what handles it. The skeleton you built in Creating a Laravel Project boots and returns 404 for everything you type into the browser; a route is what closes that gap. In Laravel 11+, routes/web.php is loaded by default through withRouting in bootstrap/app.php; routes/api.php is not registered until you run php artisan install:api, which publishes it plus the api middleware group.
use App\Http\Controllers\OrderController;
use Illuminate\Support\Facades\Route;
Route::get('/orders', [OrderController::class, 'index']);
Route::get('/orders/{order}', [OrderController::class, 'show']);
Route::post('/orders', [OrderController::class, 'store']);
Each line is a verb + URI + handler triple. The verb is the HTTP method — get, post, put, patch, delete. The URI has no leading-slash segment confusion: you write '/orders' or 'orders', both register the same path. The handler is either a closure or a Controller@method pair.
Closures are right for one-off pages and quick spikes:
Route::get('/health', fn () => response()->json(['ok' => true]));
But controllers are the default for anything with logic, because controllers can be type-hinted for dependency injection, organized by resource, and — critically — cached. Route caching is one of the reasons closures are discouraged in real apps: php artisan route:cache fails on routes that define closures, because the cached route file is serialized and closures cannot be serialized. A route closure in a production app blocks one of the cheapest performance wins Laravel offers.
Matching happens in registration order. Two routes with the same verb and URI — the first one wins:
Route::get('/orders/latest', [OrderController::class, 'latest']);
Route::get('/orders/{order}', [OrderController::class, 'show']); // {order} never matches 'latest' if 'latest' is declared first
Declare static paths before parameterized ones. If you reverse them, /orders/latest matches {order} and you spend an afternoon wondering why Order::findOrFail('latest') throws a 404.
When you need the full picture of what is registered, ask artisan:
php artisan route:list
php artisan route:list --path=orders
php artisan route:list -v # includes middleware per route
The output lists Method, URI, Name, Action, and Middleware for every registered route. It is the fastest way to answer "what URL do I call?" and "which middleware runs on this endpoint?" without reading any code. If a route "does not exist" in the browser but route:list shows it, the problem is usually a wrong APP_URL, a missing install:api for API routes, or a stale route:cache.
Route files can also be split for larger apps. Laravel 11 lets you register extra route files in bootstrap/app.php:
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
channels: __DIR__.'/../routes/channels.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
Each file gets its own middleware stack: routes in web.php receive session and CSRF middleware, while api.php routes receive stateless middleware and the throttle:api limiter. That split is why a cookie session works on web routes but does not automatically authenticate an API request — the two files run through different groups, and knowing which group your route landed in answers a whole class of auth bugs. If an endpoint "forgets" the logged-in user between requests, check the middleware group before you suspect the session driver.
Verb semantics are worth one more pass because interviewers like them. GET must be safe and idempotent — browsers, proxies, and crawlers prefetch GETs, so a GET that mutates data will be triggered without your consent. POST creates, PUT replaces, PATCH partially updates, and DELETE removes. Laravel accepts PUT and PATCH interchangeably in resource routes, but the semantics still matter for API design: a client can assume PATCH will not wipe fields you omitted, while PUT is expected to send the full representation.
Route Parameters and Model Binding: From {id} to a Model Instance
A URI like /orders/{order} declares a parameter. Laravel injects it into the handler by name — closure parameters and controller method parameters are matched to the URI segments by position and name:
Route::get('/orders/{order}', function (string $order) {
return Order::findOrFail($order);
});
That works, but it makes you call findOrFail by hand on every route. Route model binding does it for you. Type-hint the parameter as a model and Laravel resolves it — implicit binding runs a query on the model's route key (the id column by default) and hands you a live model, or a 404 if nothing matches:
Route::get('/orders/{order}', function (App\Models\Order $order) {
return view('orders.show', compact('order'));
});
No findOrFail, no manual query, no use noise in the body. If the id does not exist, Laravel throws ModelNotFoundException, which the exception handler converts to a NotFoundHttpException — an HTTP 404 page. That conversion is why model binding never leaks a 500 to your users on a bad id.
Binding by a non-id column is one line. Override the route key on the model:
// app/Models/User.php
public function getRouteKeyName(): string
{
return 'uuid';
}
...or scope it to a single route with a binding field:
Route::get('/users/{user:email}', function (App\Models\User $user) {
// resolves on email, not id
});
Nested resources need scoped bindings so the child is looked up inside the parent. Without scoping, /users/5/posts/99 would happily return post 99 even if it belongs to user 3:
Route::get('/users/{user}/posts/{post}', function (App\Models\User $user, App\Models\Post $post) {
return $post;
})->scopedBindings();
With scopedBindings(), the post is queried with a WHERE user_id = {user} constraint. Security through URL structure — a user cannot enumerate another user's posts by guessing ids.
Optional parameters use a ? and a default in the handler:
Route::get('/tags/{tag?}', function (string $tag = 'all') {
return view('tags.index', compact('tag'));
});
And you can constrain what a parameter accepts, which prevents needless database queries on garbage input:
Route::get('/orders/{order}/invoices/{invoice}', ...)
->whereNumber('order')
->whereAlphaNumeric('invoice');
// or a regex: ->where(['order' => '[0-9]+'])
The where* methods run before any handler or binding executes — a non-numeric {order} never reaches Eloquent, it falls through to a 404 immediately.
Binding also interacts with middleware ordering. The auth middleware redirects unauthenticated users to the login page on web routes, and it runs before the model is resolved — so an anonymous visitor never triggers a database lookup for a record they cannot see. This ordering matters for performance: on a public site with heavy traffic, expensive bindings stay behind the auth gate instead of hammering the database with queries for every stray hit.
Customizing a binding miss is a common follow-up once the defaults stop fitting. Returning JSON instead of the 404 page, for example, belongs in the withExceptions block of bootstrap/app.php:
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*') || $request->wantsJson()) {
return response()->json(['message' => 'Record not found.'], 404);
}
});
})
The same hook catches binding failures and validation failures alike, because both surface as HttpExceptions the handler can inspect. When you need tighter control over a single model — say, a slug that must also be active — override resolveRouteBinding on the model itself and add the constraint there, keeping the check in one place instead of repeating it in every controller method.
Finally, watch for a subtle collision: a route parameter name must match the controller's argument name, or the injection silently fails. Route::get('/orders/{order}', [OrderController::class, 'show']) paired with a method signature function show(Order $id) will not bind — Laravel matches parameters to arguments by name, so {order} needs an $order argument. The mismatch fails silently rather than loudly: binding still resolves {order} into the order parameter, while the container builds a fresh, empty Order for $id — so the method receives a blank model instead of the row the URL identified, and the page renders empty fields instead of a 404.
Resource Controllers and Naming: Cutting Boilerplate with Route::resource
A "resource" in REST terms is a noun with standard operations: list, create, store, show, edit, update, delete. Writing those seven routes by hand for every model is repetitive, so Laravel generates them in one line:
Route::resource('orders', OrderController::class);
That single call registers seven routes across five verbs:
| Method | URI | Name | Action | Purpose |
|---|---|---|---|---|
| GET | /orders |
orders.index |
index |
list all |
| GET | /orders/create |
orders.create |
create |
show new-form |
| POST | /orders |
orders.store |
store |
persist new |
| GET | /orders/{order} |
orders.show |
show |
show one |
| GET | /orders/{order}/edit |
orders.edit |
edit |
show edit-form |
| PUT/PATCH | /orders/{order} |
orders.update |
update |
persist changes |
| DELETE | /orders/{order} |
orders.destroy |
destroy |
delete |
The Name column is the route's name — a stable handle you use to generate URLs. Instead of hardcoding '/orders/5' in a Blade template, you write route('orders.show', $order) and the URL is derived from the route definition. Rename the URI path next week and every link in the app keeps working. Never hardcode URIs in views; always use route() — it is the single habit that makes refactoring routes safe.
Generating the controller with the matching methods:
php artisan make:controller OrderController --resource
# or, tied to a model with type-hinted binding already wired:
php artisan make:controller OrderController --resource --model=Order
The --model flag writes method signatures like show(Order $order) so implicit binding works out of the box — the boilerplate you would have typed by hand is already there.
You rarely want all seven routes. Trim with only or except:
Route::resource('orders', OrderController::class)
->only(['index', 'show', 'store']);
Route::resource('users', UserController::class)
->except(['edit', 'create']); // a JSON API never needs form pages
Sub-resources and nesting use the ->parameters() or shallow form; a common shape is a nested resource where the child belongs to the parent:
Route::apiResource('users.posts', PostController::class);
// registers /users/{user}/posts/{post} with model binding on both
apiResource is resource minus the create and edit HTML routes — the right default for JSON endpoints.
Finally, group variations that keep related routes DRY:
Route::prefix('admin')->middleware('auth')->group(function () {
Route::resource('orders', AdminOrderController::class);
Route::get('/dashboard', [AdminDashboard::class, 'index'])->name('admin.dashboard');
});
Every route inside gets /admin prepended and the auth middleware applied. Check the result with php artisan route:list --path=admin — the names, URIs, and middleware all appear exactly as they will run.
Resource routes compose with named-route URL generation in a pattern worth memorizing: forms post to route('orders.store'), edit pages link to route('orders.update', $order) with @method('PUT'), and delete buttons submit to route('orders.destroy', $order) with @method('DELETE'). Browsers only send GET and POST natively, so the PUT and DELETE verbs are method-spoofed through a hidden _method field — you will build exactly this in the Blade topic. Until then, the mental model is: the route table speaks full REST, HTML forms speak GET and POST, and Laravel bridges the gap in between.
Two quality-of-life flags round out route:list. With the verbose flag -v you see the middleware stack applied to each route, which is the fastest way to confirm whether auth, throttle, or a custom group actually wraps your endpoint. And php artisan route:list --except-vendor strips package-registered routes so only your application's routes remain — useful when a dozen Horizon or Telescope routes crowd the output and you are hunting for one line of your own. Route names also double in artisan commands and redirects: return to(route('orders.show', $order)) after a successful store action keeps the post-redirect-get flow clean, and since the name resolves the current URI pattern, refactoring the path later never breaks the redirect.
Now that URLs reach handlers and models, the handlers need something to return: HTML from Blade templates, which is where escaping and layouts come in — see Blade Templates and Layouts.
Quiz
1. php artisan route:cache fails with 'Unable to prepare route [/health] for serialization'. The route was defined as Route::get('/health', fn () => 'ok'). What is the problem?
2. A route is declared as Route::get('/orders/{order}', fn (Order $order) => $order) and a request arrives for /orders/999 where no order 999 exists. What response does Laravel produce?
3. You declare Route::get('/orders/latest', ...) AFTER Route::get('/orders/{order}', ...). What happens when a GET /orders/latest request arrives?
4. In a Blade view, a link is written as <a href="/orders/{{ $order->id }}">. What is the main problem with this approach?
Flashcards
Question
What three parts make up a Laravel route definition, and where do they live by default?
Click to reveal answer
Answer
An HTTP verb (get, post, put, patch, delete), a URI pattern, and a handler (closure or Controller@method). Web routes live in routes/web.php, loaded via withRouting in bootstrap/app.php; API routes require php artisan install:api to publish and register routes/api.php.
Question
How does implicit route model binding work, and what happens when the record does not exist?
Click to reveal answer
Answer
Type-hint a route parameter as an Eloquent model and Laravel queries it by the route key (id by default), injecting the found instance. A miss throws ModelNotFoundException, converted to a 404. Customize the key with getRouteKeyName() or a {user:email} binding field.
Question
Why can routes defined with closures not be used with php artisan route:cache?
Click to reveal answer
Answer
Route caching serializes the route table into a cached PHP file, and anonymous functions cannot be serialized. Move logic into controller methods so the route references a serializable class, then route:cache succeeds and route lookups skip file parsing on every request.
Question
How many routes does Route::resource register, and which two are dropped by apiResource?
Click to reveal answer
Answer
Route::resource registers seven routes: index, create, store, show, edit, update, destroy (GET create and GET edit are the form pages). apiResource drops create and edit, leaving the five JSON-appropriate routes. Trim further with ->only([...]) or ->except([...]).
Question
Why use route('orders.show', $order) instead of a hardcoded '/orders/5' string in views?
Click to reveal answer
Answer
route() looks up the route by name and generates the URI from its current definition, so changing the path pattern updates every link automatically. It also handles encoding, optional parameters, and query strings. Hardcoded URIs silently break whenever the route table changes.
Revision Notes
Key Takeaways
- 1. Routes are verb + URI + handler triples in routes/web.php; registration order decides matches, so static paths must precede {parameter} ones
- 2. Implicit model binding type-hints a parameter as a model, queries by route key, and converts a miss into a 404 via ModelNotFoundException
- 3. Closures cannot be serialized, so they block php artisan route:cache — controllers keep the route table cacheable
- 4. Route::resource registers seven named routes; apiResource drops the two form-page routes and ->only/->except trim further
- 5. route('orders.show', $order) generates URLs from the route table, making path changes safe across every view
Interview Tips
- • "What does Route::resource give you?" — seven routes, five verbs, and their names; then immediately mention apiResource and only/except so you show the REST-aware subset
- • "How does route model binding avoid an N+1 or a manual findOrFail?" — it runs one query per binding and throws ModelNotFoundException that becomes a 404; add getRouteKeyName for uuid binding
- • "Why should routes avoid closures in production?" — route:cache serializes the table and closures are not serializable; caching then costs a file read instead of re-parsing route files per request
- • "How do you protect a nested resource?" — scopedBindings() constrains the child query to the parent's id, so /users/1/posts/99 cannot return another user's post
Cheat Sheet
Routing and Controllers — Cheat Sheet
Declaration:
Route::get('/orders', [OrderController::class, 'index']);
Route::post('/orders', [OrderController::class, 'store']);
Route::get('/health', fn () => 'ok'); // fine for spikes; breaks route:cache
Binding:
Route::get('/orders/{order}', fn (Order $order) => $order); // implicit, 404 on miss
Route::get('/users/{user:email}', ...); // bind on a column
Route::get('/users/{u}/posts/{p}', ...)->scopedBindings(); // child scoped to parent
Route::get('/t/{tag?}', ...)->whereNumber('tag'); // optional + constraint
Resources:
| Call | Routes | Notes |
|---|---|---|
Route::resource |
7 | index, create, store, show, edit, update, destroy |
Route::apiResource |
5 | drops create + edit form routes |
->only([...]) / ->except([...]) |
subset | trim to what the app uses |
Grouping: Route::prefix('admin')->middleware('auth')->group(fn () => ...).
Inspection: php artisan route:list [--path=orders] [-v] — method, URI, name, action, middleware.
Rules of thumb:
- Static routes before parameterized ones (first match wins)
- Controllers over closures so
route:cacheworks route('name', $model)in every view — never hardcoded URIs