🚦 Routing and Controllers
Routes decide which code answers a URL; controllers are that code. Together they form the traffic system of a Laravel app — matching every incoming request to a handler, capturing the data it carries, and returning the right response.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define routes with different HTTP verbs, parameters, and constraints
- Use named routes and route groups to stay DRY and organised
- Apply route model binding to load models automatically
- Build RESTful resource controllers and validate incoming requests
- Attach middleware to filter requests before they reach a controller
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build the routes and controllers for a small product catalogue.
In This Lesson
What Routing Does
Routing is the mechanism that connects an incoming HTTP request to the code that handles it. If a controller is a department manager, a route is the receptionist who reads each visitor's request and points them to the right desk.
Laravel keeps routes in the routes/ directory, split by purpose:
| File | Purpose |
|---|---|
web.php | Browser-facing routes — sessions, cookies, and CSRF protection applied |
api.php | Stateless API routes returning JSON (add it with php artisan install:api) |
console.php | Custom Artisan (CLI) commands |
channels.php | WebSocket broadcasting channel authorisation |
A useful mental model is a postal sorting office: every letter (request) carries an address (URL) and a class of service (HTTP verb), and the sorting rules (routes) decide where it is delivered.
Defining Routes
Routes are registered by calling a method named after the HTTP verb on the Route facade. The handler is either a closure or, far more commonly in real apps, a reference to a controller method.
<?php
use App\Http\Controllers\UserController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
// A closure handler (fine for tiny things)
Route::get('/welcome', fn () => 'Hello, World!');
// The usual form: point a route at a controller method
Route::get('/users', [UserController::class, 'index']);
// A named route (we'll use the name to generate URLs later)
Route::get('/profile', [ProfileController::class, 'show'])->name('profile');
// Match other verbs
Route::post('/users', [UserController::class, 'store']);
Route::put('/users/{user}', [UserController::class, 'update']);
Route::delete('/users/{user}', [UserController::class, 'destroy']);
Every route definition combines four things:
- HTTP verb —
get,post,put,patch,delete… - URI pattern — the path to match, e.g.
/users/{id} - Handler — a closure or
[Controller::class, 'method'] - Modifiers — optional extras chained on, like
->name(),->middleware(), or->where()
Parameters & Named Routes
Route parameters
Curly braces capture a segment of the URL and pass it to the handler as an argument. Think of parameters as the fields on a form — they collect the specific data a request needs.
<?php
// A single parameter
Route::get('/users/{id}', function (string $id) {
return 'User ' . $id;
});
// Multiple parameters, in order
Route::get('/posts/{post}/comments/{comment}', function ($postId, $commentId) {
return "Post {$postId}, Comment {$commentId}";
});
// An optional parameter (note the ? and the default value)
Route::get('/posts/{slug?}', function (?string $slug = null) {
return $slug ?? 'All posts';
});
Constraints
Constraints restrict a parameter to a pattern, rejecting anything that does not match before your code runs — like input validation for URLs.
<?php
// Only numeric IDs
Route::get('/user/{id}', [UserController::class, 'show'])
->where('id', '[0-9]+');
// A slug of letters, numbers, hyphens, underscores
Route::get('/post/{slug}', [PostController::class, 'show'])
->where('slug', '[A-Za-z0-9\-_]+');
// Fluent helpers read even better
Route::get('/user/{id}', [UserController::class, 'show'])->whereNumber('id');
Route::get('/category/{name}', [CategoryController::class, 'show'])->whereAlpha('name');
Named routes
Naming a route lets you refer to it by name instead of hard-coding its URL. If the path ever changes, you update it in one place and every reference follows.
<?php
Route::get('/user/{id}/profile', [ProfileController::class, 'show'])
->name('user.profile');
// Generate a URL to it
$url = route('user.profile', ['id' => 1]); // /user/1/profile
// Redirect to it
return redirect()->route('user.profile', ['id' => 1]);
✅ Why named routes matter
Imagine a "view product" link appearing in a dozen templates. Name the route products.show, reference it everywhere with route('products.show', $product), and you can later change the URL from /products/{id} to /shop/items/{id} by editing a single line.
Route Groups
Route groups share attributes — a URL prefix, middleware, a name prefix — across many routes so you do not repeat yourself. They are like cable ties: bundle related routes and apply common settings to all of them at once.
<?php
// Shared prefix: URLs become /admin/users and /admin/posts
Route::prefix('admin')->group(function () {
Route::get('/users', [AdminController::class, 'users']);
Route::get('/posts', [AdminController::class, 'posts']);
});
// Shared middleware: both routes require authentication
Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/settings', [SettingsController::class, 'index']);
});
// Combine several attributes fluently
Route::prefix('admin')
->middleware(['auth', 'can:admin'])
->name('admin.')
->group(function () {
// name = admin.users, URL = /admin/users, guarded by both middleware
Route::get('/users', [AdminController::class, 'users'])->name('users');
});
Groups can nest, which is handy for API versioning:
<?php
Route::prefix('api')->group(function () {
Route::prefix('v1')->group(function () {
Route::apiResource('products', ProductApiController::class);
// URL: /api/v1/products
});
});
Route Model Binding
Route model binding automatically resolves an Eloquent model from a route parameter — no manual database query needed. It is like a personal assistant who hands you the whole client file the moment you mention the client's name.
Implicit binding
When the parameter name matches a type-hinted model variable, Laravel fetches it for you and returns a 404 if it is not found.
<?php
// The {user} segment is resolved to a User model automatically
Route::get('/users/{user}', [UserController::class, 'show']);
// In UserController — $user is already loaded
public function show(User $user)
{
return view('users.show', ['user' => $user]);
}
Custom keys
By default binding uses the id column. To bind by a friendlier column such as a slug, name it in the route:
<?php
// Resolve the post by its slug column instead of id
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
💡 Cleaner URLs, for free
Custom-key binding lets a blog serve /posts/laravel-routing-guide instead of /posts/42 while still loading the correct model automatically.
RESTful Resource Controllers
Most CRUD screens follow the same seven actions. Laravel's resource routes generate all of them from a single line, keeping your URLs and method names consistent — like a standardised process on a factory line.
<?php
// One line registers seven routes
Route::resource('photos', PhotoController::class);
| Verb | URI | Action | Route name | Purpose |
|---|---|---|---|---|
| GET | /photos | index | photos.index | List all resources |
| GET | /photos/create | create | photos.create | Show the "new" form |
| POST | /photos | store | photos.store | Save a new resource |
| GET | /photos/{photo} | show | photos.show | Show one resource |
| GET | /photos/{photo}/edit | edit | photos.edit | Show the "edit" form |
| PUT/PATCH | /photos/{photo} | update | photos.update | Update a resource |
| DELETE | /photos/{photo} | destroy | photos.destroy | Delete a resource |
Generate a matching controller with all seven methods stubbed out:
php artisan make:controller PhotoController --resource --model=Photo
You can trim or reshape the set:
<?php
// Only a subset of actions
Route::resource('photos', PhotoController::class)->only(['index', 'show']);
// Everything except a few
Route::resource('photos', PhotoController::class)->except(['destroy']);
// API resources skip the create/edit form routes (JSON has no forms)
Route::apiResource('photos', PhotoApiController::class);
Single-action controllers
When a controller does exactly one thing, use the __invoke method and reference the class directly:
<?php
namespace App\Http\Controllers;
use App\Models\User;
class ShowProfile extends Controller
{
public function __invoke(User $user)
{
return view('profile', ['user' => $user]);
}
}
// Route registration — no method name needed
Route::get('/user/{user}', ShowProfile::class);
Request Validation & Responses
Controllers receive an Illuminate\Http\Request that carries all input. Validate it before you trust it — the receptionist not only directs visitors but checks their paperwork first.
<?php
public function store(Request $request)
{
// Only validated data is returned; validation failures redirect back
$validated = $request->validate([
'title' => 'required|max:255',
'body' => 'required',
'publish_at' => 'nullable|date',
]);
$article = Article::create($validated);
return redirect()->route('articles.show', $article)
->with('status', 'Article created!');
}
For complex rules, extract validation into a dedicated Form Request class. It keeps controllers thin and bundles authorisation with the rules:
<?php
// php artisan make:request StoreArticleRequest
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreArticleRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Article::class);
}
public function rules(): array
{
return [
'title' => 'required|max:255',
'body' => 'required',
'category_id' => 'required|exists:categories,id',
];
}
}
// The controller now stays tiny — validation happens before it runs
public function store(StoreArticleRequest $request)
{
$article = Article::create($request->validated());
return redirect()->route('articles.show', $article);
}
Response types
A controller can return many kinds of response — choose the format that fits the client:
<?php
return view('profile', ['user' => $user]); // rendered HTML
return response()->json(['users' => User::all()]); // JSON (APIs)
return redirect()->route('dashboard'); // redirect
return response()->download($path, 'invoice.pdf'); // file download
return response('OK', 200)->header('X-App', 'demo');// custom response
Middleware
Middleware are layers a request must pass through before reaching a controller (and that the response passes back through). Authentication, CSRF protection, and rate limiting are all middleware. Picture airport security checkpoints: every passenger clears them before reaching the gate.
<?php
// One middleware
Route::get('/profile', [ProfileController::class, 'show'])
->middleware('auth');
// Several at once
Route::get('/admin', [AdminController::class, 'index'])
->middleware(['auth', 'can:admin']);
// Applied to a whole group
Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/settings', [SettingsController::class, 'edit']);
});
Create your own to enforce custom rules — here, requiring an active subscription:
<?php
// php artisan make:middleware EnsureSubscribed
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSubscribed
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && ! $request->user()->subscribed) {
return redirect()->route('billing');
}
return $next($request); // pass control to the next layer
}
}
⚠️ Registering middleware in Laravel 11+
Recent Laravel no longer uses an app/Http/Kernel.php. Register middleware aliases and groups in bootstrap/app.php inside the ->withMiddleware(...) callback (for example, $middleware->alias(['subscribed' => EnsureSubscribed::class]);). Older tutorials that edit the Kernel are out of date.
Hands-on Exercise
🏋️ Build a Product Catalogue
Objective: Wire up public and admin routes plus controllers for a simple product catalogue, practising resource routes, groups, and model binding.
Instructions:
- Generate a model with a migration:
php artisan make:model Product -m(fields: name, slug, description, price, category_id). - Generate resource controllers for the public and admin sides.
- In
routes/web.php, add public routes for browsing products (bind by slug) and anadmingroup, guarded byauth, exposing full CRUD. - Implement
index(list, filterable by category) andshow(single product).
💡 Hint
Use Route::prefix('admin')->middleware('auth')->name('admin.')->group(...) for the admin side, and Route::get('/products/{product:slug}', ...) for clean public URLs. A resource controller can be created with php artisan make:controller Admin/ProductController --resource --model=Product.
✅ Example solution
<?php
// routes/web.php
use App\Http\Controllers\ProductController;
use App\Http\Controllers\Admin\ProductController as AdminProductController;
use Illuminate\Support\Facades\Route;
// Public — clean slug URLs
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::get('/products/{product:slug}', [ProductController::class, 'show'])->name('products.show');
// Admin — grouped, guarded, name-prefixed
Route::prefix('admin')
->middleware(['auth', 'can:admin'])
->name('admin.')
->group(function () {
Route::resource('products', AdminProductController::class);
});
<?php
// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request)
{
$products = Product::query()
->when($request->query('category'), function ($q, $slug) {
$q->whereHas('category', fn ($c) => $c->where('slug', $slug));
})
->latest()
->paginate(12);
return view('products.index', ['products' => $products]);
}
public function show(Product $product) // bound by slug from the route
{
return view('products.show', ['product' => $product]);
}
}
🎯 Quick Quiz
Question 1: How many routes does Route::resource('photos', PhotoController::class) register?
Question 2: What does route model binding do?
Question 3: Where do you register a custom middleware alias in Laravel 11+?
Summary & Quiz
🎉 Key Takeaways
- Routes map a verb + URI to a handler; controllers group related request logic.
- Parameters capture URL segments; constraints restrict them; named routes decouple code from URLs.
- Route groups share prefixes, middleware, and name prefixes to keep routes DRY.
- Route model binding loads models automatically — even by a custom key like a slug.
- Resource controllers generate the seven RESTful routes; validate input (ideally with Form Requests) and guard routes with middleware.
📚 Further Reading
- Laravel Docs — Routing
- Laravel Docs — Controllers
- Laravel Docs — Validation
- Laravel Docs — Middleware
🚀 What's Next?
Your controllers keep returning views. Next we open up those views with the Blade template engine — how to render data, reuse layouts, build components, and handle forms safely.
🎉 Well done!
You can now route any request to the right code and hand back the right response.