Laravel 13 tutorial #7: routes, controllers and views
verified on 7 September 2026 · 8 min
Each page gets a controller that queries the models and returns a view. Always add with(['category', 'user']) to your lists: on five posts, eager loading takes the page from 11 queries down to 3.
The data is there, and the admin panel can create it. This chapter puts it on screen: the home page, the category and tag pages, and the single post page.
Creating the controllers
Three controllers have only one thing to do, hence --invokable. IndexController already exists since chapter 5: its content is replaced further down. PostController will have two methods: displaying a post, and handling the search in the next chapter.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerCreate them before writing the routes: a route of the form Route::get('/…', CategoryController::class) requires the class to exist, otherwise no artisan command starts any more.
The routes
<?php
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\IndexController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\TagController;
use Illuminate\Support\Facades\Route;
Route::get('/', IndexController::class)->name('home');
Route::get('/recherche', [PostController::class, 'search'])->name('search');
Route::get('/categorie/{category}', CategoryController::class)->name('category');
Route::get('/etiquette/{tag}', TagController::class)->name('tag');
Route::get('/article/{post}', [PostController::class, 'show'])->name('post');Every route is named: the views build their links with route('post', $post), never with a hard-coded address.
Thanks to getRouteKeyName() declared on the models in chapter 6, the {category}, {tag} and {post} parameters are resolved against the slug column. Laravel loads the record and returns a 404 when nothing matches, before the controller is even reached.
/recherche is declared before the routes that take a parameter. Had you written Route::get('/{slug}', …) above it, that route would swallow /recherche and look for a post with that slug. Laravel stops at the first route that matches: fixed addresses go before variable ones.
The home controller
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class IndexController extends Controller
{
public function __invoke(): View
{
return view('home', [
'posts' => Post::published()
->with(['category', 'user'])
->latest('published_at')
->paginate(5),
'featuredPosts' => Post::published()->featured()
->latest('published_at')
->take(5)
->get(),
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}Four methods deserve an explanation.
paginate(5)cuts the list into pages of five posts. How it works, and how the links are rendered, is covered in chapter 9.published()is the scope from chapter 6. It replaceswhere('is_published', true), otherwise repeated across the five controllers.latest('published_at')sorts by descending date. With no argument the method sorts oncreated_at, which is not the same thing: a post written on Monday and published on Friday belongs at its publication date.withCount('posts')adds aposts_countcolumn to every category, computed with a sub-query. That is the number the sidebar shows in brackets, without loading the posts themselves.with(['category', 'user'])is the one that matters. See below.
The N+1 query problem
The post list shows, for each post, the name of its category and the name of its author. Left alone, Eloquent loads the five posts first, then fetches the category of each one, then the author of each one: one query for the list, plus ten. At fifty posts per page, one hundred and one queries.
with(['category', 'user']) asks Eloquent to load the relations up front, one query per relation. The application log shows it:
select * from "posts" where "is_published" = 1 order by "published_at" desc limit 5 offset 0
select * from "categories" where "categories"."id" in (1, 2)
select * from "users" where "users"."id" in (1)Three queries instead of eleven, and the count no longer moves when the page grows. This is the best-value optimisation in a Laravel application, and the one most often forgotten.
The category controller
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class CategoryController extends Controller
{
public function __invoke(Category $category): View
{
return view('category', [
'category' => $category,
'posts' => $category->posts()
->published()
->with(['category', 'user'])
->latest('published_at')
->paginate(5),
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}The Category $category argument is already the loaded object: Laravel resolved it from the slug in the URL. There is no query to write, and the “no such category” case is dealt with before the method runs.
$category->posts(), with the brackets, returns a query you can refine. Without them, $category->posts hands back the whole collection, with no filter and no pagination. The distinction is fundamental, and a frequent source of confusion.
The tag controller
Exactly the same shape, with Tag:
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class TagController extends Controller
{
public function __invoke(Tag $tag): View
{
return view('tag', [
'tag' => $tag,
'posts' => $tag->posts()
->published()
->with(['category', 'user'])
->latest('published_at')
->paginate(5),
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}The post controller
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;
class PostController extends Controller
{
public function show(Post $post): View
{
abort_unless($post->is_published, 404);
$post->load(['category', 'user', 'tags']);
return view('post', [
'post' => $post,
'categories' => Category::withCount('posts')->orderBy('name')->get(),
'tags' => Tag::orderBy('name')->get(),
'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
]);
}
}abort_unless($post->is_published, 404) is worth a pause. Route model binding finds the post by its slug whether it is published or not: without this line, a draft is visible to anyone who guesses its address. The line returns a 404 rather than a 403, so it does not even reveal that the post exists.
load() is the equivalent of with() on an object that is already loaded. Without it, rendering the tags would fire one more query.
The views
The three listing pages reuse the partial view written in chapter 5.
@extends('layouts.app')
@section('title', $category->name)
@section('content')
<h1 class="mb-2 text-2xl font-bold">Catégorie : {{ $category->name }}</h1>
@if ($category->description)
<p class="mb-6 text-gray-600">{{ $category->description }}</p>
@endif
@include('partials.posts-list')
@endsection@extends('layouts.app')
@section('title', $tag->name)
@section('content')
<h1 class="mb-6 text-2xl font-bold">Étiquette : {{ $tag->name }}</h1>
@include('partials.posts-list')
@endsectionThe single post page
@extends('layouts.app')
@section('title', $post->title)
@section('description', $post->excerpt ?: Str::limit(strip_tags($post->content), 150))
@section('content')
<article class="rounded border bg-white p-6">
<h1 class="text-3xl font-bold">{{ $post->title }}</h1>
<p class="mt-2 text-sm text-gray-500">
{{ $post->user?->name }} —
<a href="http://%20route('category',%20$post-category)%20" class="hover:underline">{{ $post->category?->name }}</a>
@if ($post->published_at) — {{ $post->published_at->translatedFormat('j F Y') }} @endif
</p>
@if ($post->featured_image)
<img src="{{ Storage::url($post->featured_image) }}" alt="" class="mt-4 rounded">
@endif
<div class="prose mt-6 max-w-none">{!! $post->content !!}</div>
@if ($post->tags->isNotEmpty())
<ul class="mt-6 flex flex-wrap gap-2 text-sm">
@foreach ($post->tags as $tag)
<li><a href="http://%20route('tag',%20$tag)%20" class="rounded bg-gray-200 px-2 py-0.5">{{ $tag->name }}</a></li>
@endforeach
</ul>
@endif
</article>
@endsection{!! $post->content !!} prints the HTML produced by the admin editor without escaping it. That is legitimate here because the content comes from an authenticated administrator. Never write it that way for anything a visitor has typed.
Storage::url() builds the public address of an uploaded file. It assumes php artisan storage:link was run back in chapter 1, without it the image returns a 404.
Checking it works
All four pages must respond. A test guarantees it on every future change. The complete file, with the makePost() method that creates a test post, is given in chapter 9, here is the method that protects drafts:
public function test_un_article_non_publie_est_invisible(): void
{
$this->makePost(['is_published' => false]);
$this->get('/')->assertOk()->assertDontSee('Premier article');
$this->get('/article/premier-article')->assertNotFound();
}php artisan testThe test shipped with Laravel, tests/Feature/ExampleTest.php, loads the home page without a database: now that the home page queries the posts table, it fails with no such table: posts. Add the RefreshDatabase trait to it, as in the tests of this tutorial, or delete it.
The next chapter adds the search engine.