Routing & Controllers
Routes, parameters, named routes, controllers and resource controllers.
Routes are the front door of a Laravel app. They map an HTTP verb and a URL pattern to the code that handles it. This lesson uses the real routing of this app: the CMS and tutorial routes in routes/web.php, and the Projects & Tasks demo API in routes/api.php. It covers parameters and constraints, named routes, groups, controllers and route model binding, then resource controllers.
Code captioned with a file path is copied from this repository. Blocks marked Illustrative show patterns the example doesn't use.
Your first routes
Routes live in routes/web.php (browser routes with sessions and CSRF) and optionally routes/api.php (stateless JSON routes). This app registers both in bootstrap/app.php. Every route in api.php is automatically prefixed with /api and gets the api middleware group:
bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
// ...
Illustrative. The simplest routes are closures:
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('welcome'));
Route::get('/about', fn () => 'Projects & Tasks: a Laravel demo app');
Route::post('/contact', function (Request $request) {
// ...
return back()->with('status', 'Thanks!');
});
Every HTTP verb has a method, plus helpers for common cases:
Route::get($uri, $action);
Route::post($uri, $action);
Route::put($uri, $action);
Route::patch($uri, $action);
Route::delete($uri, $action);
Route::match(['get', 'post'], '/search', $action);
Route::any('/webhook', $action);
Route::redirect('/home', '/dashboard', 301);
Route::view('/terms', 'pages.terms'); // no controller needed
HTML forms can only send GET and POST. For PUT, PATCH and DELETE, Blade's @method('DELETE') adds a hidden _method field, and Laravel spoofs the verb.
Route parameters and constraints
Wrap a segment in braces to capture it. Add ? (with a default) to make it optional. Illustrative:
Route::get('/projects/{id}', fn (int $id) => "Project #{$id}");
Route::get('/projects/{id}/tasks/{status?}', function (int $id, string $status = 'open') {
return "Project {$id}, {$status} tasks";
});
Constraints stop a route from matching the wrong thing. This matters most when you have a catch-all. Here's the complete web routes file of the site you're reading:
routes/web.php
Route::get('/', [PageController::class, 'home']);
Route::get('/blog', [BlogController::class, 'index']);
Route::get('/blog/type/{typeSlug}', [BlogController::class, 'byType'])->where('typeSlug', '[a-z0-9\-]+');
Route::get('/blog/{slug}', [BlogController::class, 'show'])->where('slug', '[a-z0-9\-]+');
Route::get('/tutorials', [TutorialController::class, 'index'])->name('tutorials.index');
Route::get('/tutorials/{track}', [TutorialController::class, 'track'])->where('track', '[a-z0-9\-]+')->name('tutorials.track');
Route::get('/tutorials/{track}/{slug}', [TutorialController::class, 'show'])->where(['track' => '[a-z0-9\-]+', 'slug' => '[a-z0-9\-]+'])->name('tutorials.show');
// Catch-all for CMS pages — keep last
Route::get('/{slug}', [PageController::class, 'show'])->where('slug', '[a-z0-9\-]+');
/{slug} hands any single-segment URL to the CMS, which looks up a Page by slug. Registered first, it would swallow /blog and /tutorials. The [a-z0-9\-]+ constraint also stops it from matching anything with a slash or dot, so /tutorials/vue or /favicon.ico never reach it.
Laravel provides shorthand helpers for common patterns. Illustrative:
Route::get('/projects/{project}', ...)->whereNumber('project');
Route::get('/users/{user:uuid}', ...)->whereUuid('user');
Route::get('/tags/{tag}', ...)->whereAlpha('tag');
Route::get('/docs/{section}', ...)->whereIn('section', ['install', 'config', 'deploy']);
Route matcher playground
The router checks routes in registration order and the first match wins. This playground holds this app's real routes, the web routes followed by the demo API. Type a URL to see which route handles it, and what happens if the catch-all is moved to the top.
Named routes and URL generation
Never hard-code URLs. Name the route and generate the URL, so the path can change without breaking links. The tutorial routes above are named, and the track page builds its lesson links like this:
resources/views/tutorials/track.blade.php
<li><a href="{{ route('tutorials.show', [$trackKey, $slug]) }}">{{ $meta['title'] }}</a></li>
The demo API's ProjectResource uses a named route to include each project's own URL in the JSON. Passing the model is enough, because Laravel uses its route key (the slug):
app/Http/Resources/ProjectResource.php
'url' => route('api.demo.projects.show', $this->resource),
// → "https://demo1.matt-9.com/api/demo/projects/website-relaunch" (host taken from the current request)
Illustrative. Other URL helpers:
route('tutorials.show', ['laravel', 'routing-controllers', 'ref' => 'nav']); // extra keys become ?ref=nav
return to_route('tutorials.index'); // redirect response
request()->routeIs('tutorials.*'); // e.g. to highlight the active nav link
Grouping routes
Groups share attributes (middleware, prefix, name prefix, controller) so you don't repeat them. The demo API is one group:
routes/api.php
// Public, read-only "Projects & Tasks" API used by the tutorials' live demos
Route::prefix('demo')
->name('api.demo.')
->middleware(['throttle:demo', AddQueryCountHeader::class])
->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::get('/tasks', [TaskController::class, 'index'])->name('tasks.index');
Route::post('/tasks/validate', [TaskController::class, 'validateOnly'])->name('tasks.validate');
Route::get('/query-count', QueryCountController::class)->name('query-count');
});
Combined with the automatic /api prefix, that gives:
| Verb | URL | Name | Action |
|---|---|---|---|
| GET | /api/demo/projects |
api.demo.projects.index |
Api\ProjectController@index |
| GET | /api/demo/projects/{project} |
api.demo.projects.show |
Api\ProjectController@show |
| GET | /api/demo/tasks |
api.demo.tasks.index |
Api\TaskController@index |
| POST | /api/demo/tasks/validate |
api.demo.tasks.validate |
Api\TaskController@validateOnly |
| GET | /api/demo/query-count |
api.demo.query-count |
Api\QueryCountController (invokable) |
Every route in the group gets two middleware. throttle:demo applies a named rate limiter of 120 requests a minute per IP, defined in AppServiceProvider. AddQueryCountHeader adds the X-Query-Count and X-Response-Time headers the live demos display.
Illustrative. Groups nest, and can also share a controller:
Route::middleware('auth')->group(function () {
Route::get('/dashboard', DashboardController::class)->name('dashboard');
Route::prefix('admin')->name('admin.')->middleware('can:admin')->group(function () {
Route::get('/users', [Admin\UserController::class, 'index'])->name('users.index');
// URL: /admin/users Name: admin.users.index
});
});
Route::controller(ReportController::class)->prefix('reports')->group(function () {
Route::get('/weekly', 'weekly');
Route::get('/monthly', 'monthly');
});
Controllers
Closures are fine for tiny routes, but controllers keep logic organised and testable. They're also the norm for route caching (closures can be serialised by route:cache these days, but controllers remain the convention).
php artisan make:controller Api/ProjectController
The demo API's project controller has two actions:
app/Http/Controllers/Api/ProjectController.php
class ProjectController extends Controller
{
public function index(): AnonymousResourceCollection
{
$projects = Project::query()
->with('owner')
->withCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()])
->orderBy('name')
->get();
return ProjectResource::collection($projects);
}
public function show(Project $project): ProjectResource
{
$project->load(['owner', 'tasks' => fn ($query) => $query->orderBy('id'), 'tasks.assignee', 'tasks.tags'])
->loadCount(['tasks', 'tasks as open_tasks_count' => fn (Builder $query) => $query->open()]);
return new ProjectResource($project);
}
}
The controller stays thin. It decides what data to load, and returns an API resource that decides how it's serialised. withCount('tasks as open_tasks_count') adds a subquery count using the model's open() scope, so the list shows "6 of 8 open" without loading a single task.
Single-action controllers
When a controller only does one thing, give it an __invoke method and route straight to the class. QueryCountController is registered as Route::get('/query-count', QueryCountController::class):
app/Http/Controllers/Api/QueryCountController.php
/**
* Runs the same "projects → tasks → assignee" listing lazily or eagerly
* and reports the SQL that actually hit the database.
*/
class QueryCountController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$eager = $request->boolean('eager');
DB::flushQueryLog();
DB::enableQueryLog();
$projects = $eager
? Project::with('tasks.assignee')->orderBy('id')->get()
: Project::orderBy('id')->get();
// ... map each project → tasks → assignee name, then return the query log as JSON
}
}
Try /api/demo/query-count?eager=0 (22 queries) against /api/demo/query-count?eager=1 (3 queries). The relationships lesson explains why.
Route model binding
Look at show(Project $project) above. The route parameter is {project} and the argument is type-hinted as a Project model, so Laravel looks up the model for you. If nothing matches, you get an automatic 404 and the controller never runs.
Bind by a different column, or scope child models to their parent. Illustrative:
// /projects/website-relaunch instead of /projects/7
Route::get('/projects/{project:slug}', [ProjectController::class, 'show']);
// A task must belong to the project in the URL, or it's a 404
Route::get('/projects/{project}/tasks/{task}', [TaskController::class, 'show'])->scopeBindings();
You can also set the default key on the model itself. The demo Project model does exactly that, which is why {project} in routes/api.php matches website-relaunch rather than an ID:
app/Models/Project.php
public function getRouteKeyName(): string
{
return 'slug';
}
A missing slug becomes a JSON 404 rather than an HTML error page, because bootstrap/app.php tells the exception handler to always render JSON for api/*. A test pins that down:
tests/Feature/Demo/DemoApiTest.php
public function test_unknown_project_slug_returns_json_404(): void
{
$this->get('/api/demo/projects/does-not-exist')
->assertNotFound()
->assertHeader('Content-Type', 'application/json');
}
See it running
This calls the real GET /api/demo/projects/{project} route. The seeded slugs are website-relaunch, mobile-app-api and analytics-dashboard. Any other slug gets a 404 before the controller runs.
Open it directly: /api/demo/projects/website-relaunch.
Why scoping matters: Without scopeBindings(), /projects/1/tasks/99 would happily load task 99 even if it belongs to project 2. That's an IDOR (insecure direct object reference) bug. Scoped bindings, plus authorisation policies (covered in the Medium level), close it.
Resource controllers
Illustrative, not in this app. The demo API is read-only, but CRUD screens follow the same seven actions every time, so Laravel can generate them all:
php artisan make:controller ProjectController --resource --model=Project
Route::resource('projects', ProjectController::class);
// Nested: tasks belong to a project. shallow() drops the parent from
// member routes, so /tasks/{task} instead of /projects/{project}/tasks/{task}
Route::resource('projects.tasks', TaskController::class)->shallow();
That single line registers:
| Verb | URI | Action | Route name |
|---|---|---|---|
| GET | /projects |
index |
projects.index |
| GET | /projects/create |
create |
projects.create |
| POST | /projects |
store |
projects.store |
| GET | /projects/{project} |
show |
projects.show |
| GET | /projects/{project}/edit |
edit |
projects.edit |
| PUT/PATCH | /projects/{project} |
update |
projects.update |
| DELETE | /projects/{project} |
destroy |
projects.destroy |
Limit or rename as needed:
Route::resource('projects', ProjectController::class)->only(['index', 'show']);
Route::resource('projects', ProjectController::class)->except(['destroy']);
Route::apiResource('projects', Api\ProjectController::class); // no create/edit (no HTML forms)
A complete resource controller
Illustrative, not in this app. This is a web resource controller for projects, as you'd write it if the example had HTML screens:
class ProjectController extends Controller
{
public function index(): View
{
return view('projects.index', [
'projects' => Project::withCount('tasks')->latest()->paginate(10),
]);
}
public function create(): View
{
return view('projects.create');
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string'],
]);
$project = $request->user()->projects()->create($data + ['slug' => Str::slug($data['name'])]);
return to_route('projects.show', $project)->with('status', 'Project created.');
}
public function show(Project $project): View
{
return view('projects.show', ['project' => $project->load('tasks')]);
}
public function edit(Project $project): View
{
return view('projects.edit', compact('project'));
}
public function update(Request $request, Project $project): RedirectResponse
{
$project->update($request->validate([
'name' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string'],
]));
return to_route('projects.show', $project)->with('status', 'Saved.');
}
public function destroy(Project $project): RedirectResponse
{
$project->delete();
return to_route('projects.index')->with('status', 'Project deleted.');
}
}
Validation moves into Form Request classes in Forms & Validation, later in this Basic level, which keeps these methods even thinner.
What a controller can return
| Return value | Becomes |
|---|---|
view('x', $data) |
HTML response |
'string' |
text/html response |
['a' => 1] or a Collection |
JSON response |
| A model / paginator | JSON (serialised with toArray()) |
redirect(), back(), to_route() |
302 redirect |
response()->json($data, 201) |
JSON with a custom status |
response()->download($path) |
File download |
abort(403) |
Exception rendered as an error page |
Inspecting routes
php artisan route:list
php artisan route:list --name=projects --except-vendor
php artisan route:cache # production: serialise all routes for speed
php artisan route:clear
In production, routes are usually cached. A newly added route won't exist until php artisan route:cache runs again. This site's deploy script runs route:cache on the VPS after every release, and the local Docker entrypoint does the same on container start.
Key takeaways
- Routes map verb + URI to an action. The first matching route wins, so register catch-alls last.
- Use
where()constraints and named routes, and generate URLs withroute()rather than hard-coding them. - Groups share prefixes, name prefixes and middleware across many routes.
- Type-hinting a model gives you route model binding. Use
scopeBindings()for nested resources. Route::resource()gives you seven conventional CRUD routes mapped to one controller.