Tutoriel Laravel 13 #7 : Créer des routes, des contrôleurs et des vues
vérifié le 7 septembre 2026 · 8 min
Chaque page reçoit un contrôleur qui interroge les modèles et renvoie une vue. Ajoutez toujours with(['category', 'user']) aux listes : sur cinq articles, le chargement anticipé fait passer la page de 11 requêtes à 3.
Les données existent, le back-office permet de les saisir. Ce chapitre les affiche : la page d’accueil, les pages de catégorie et d’étiquette, et la fiche d’un article.
Créer les contrôleurs
Trois contrôleurs n’ont qu’une chose à faire, d’où --invokable. IndexController existe déjà depuis le chapitre 5 : son contenu est remplacé plus bas. PostController aura deux méthodes : afficher un article, et traiter la recherche au chapitre suivant.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerCréez-les avant d’écrire les routes : une route de la forme Route::get('/…', CategoryController::class) exige que la classe existe, sans quoi plus aucune commande artisan ne démarre.
Les 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');Chaque route est nommée : les vues construisent leurs liens avec route('post', $post), jamais avec une adresse écrite en dur.
Grâce à getRouteKeyName() déclaré sur les modèles au chapitre 6, les paramètres {category}, {tag} et {post} sont résolus sur la colonne slug. Laravel charge l’enregistrement et renvoie une 404 si rien ne correspond, avant même d’entrer dans le contrôleur.
/recherche est déclarée avant les routes à paramètre. Si vous aviez écrit Route::get('/{slug}', …) plus haut, elle capterait /recherche et chercherait un article portant ce slug. Laravel s’arrête à la première route qui correspond : les adresses fixes se placent avant les adresses variables.
Le contrôleur de l’accueil
<?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(),
]);
}
}Quatre méthodes méritent une explication.
paginate(5)découpe la liste en pages de cinq articles. Son fonctionnement et l’affichage des liens sont détaillés au chapitre 9.published()est le scope du chapitre 6. Il remplacewhere('is_published', true), répété autrement dans les cinq contrôleurs.latest('published_at')trie par date décroissante. Sans argument, la méthode trie surcreated_at, ce qui n’est pas la même chose : un article rédigé lundi et publié vendredi doit apparaître à sa date de publication.withCount('posts')ajoute une colonneposts_countà chaque catégorie, calculée par une sous-requête. C’est ce que la barre latérale affiche entre parenthèses, sans charger les articles eux-mêmes.with(['category', 'user'])est le point important. Voyez ci-dessous.
Le problème des requêtes N+1
La liste d’articles affiche, pour chacun, le nom de sa catégorie et celui de son auteur. Sans précaution, Eloquent charge d’abord les cinq articles, puis va chercher la catégorie de chacun, puis l’auteur de chacun : une requête pour la liste, plus dix. Avec cinquante articles par page, cent une requêtes.
with(['category', 'user']) demande à Eloquent de charger les relations d’avance, en une requête par relation. Les journaux de l’application le montrent :
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)Trois requêtes au lieu de onze, et le nombre ne bouge pas quand la page grandit. C’est l’optimisation la plus rentable d’une application Laravel, et la plus souvent oubliée.
Le contrôleur de catégorie
<?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(),
]);
}
}L’argument Category $category est déjà l’objet chargé : Laravel l’a résolu depuis le slug de l’URL. Il n’y a aucune requête à écrire, et le cas « catégorie inexistante » est traité avant l’entrée dans la méthode.
$category->posts() avec les parenthèses renvoie une requête que l’on peut affiner. Sans les parenthèses, $category->posts renverrait directement la collection complète, sans filtre ni pagination. La distinction est fondamentale et source de confusion fréquente.
Le contrôleur d’étiquette
Strictement le même schéma, avec 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(),
]);
}
}Le contrôleur d’article
<?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) mérite qu’on s’y arrête. La liaison de modèle trouve l’article par son slug, qu’il soit publié ou non : sans cette ligne, un brouillon serait visible de quiconque devine son adresse. La ligne renvoie une 404 plutôt qu’une 403, ce qui ne révèle même pas que l’article existe.
load() est l’équivalent de with() sur un objet déjà chargé. Sans lui, l’affichage des étiquettes déclencherait une requête supplémentaire.
Les vues
Les trois pages de liste réutilisent la vue partielle écrite au chapitre 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')
@endsectionLa fiche d’un article
@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="{{ route('category', $post->category) }}" 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="{{ route('tag', $tag) }}" class="rounded bg-gray-200 px-2 py-0.5">{{ $tag->name }}</a></li>
@endforeach
</ul>
@endif
</article>
@endsection{!! $post->content !!} affiche le HTML produit par l’éditeur du back-office sans l’échapper. C’est légitime ici parce que le contenu vient d’un administrateur authentifié. N’écrivez jamais cette forme sur une donnée saisie par un visiteur.
Storage::url() construit l’adresse publique d’un fichier téléversé. Elle suppose que php artisan storage:link a été exécuté au chapitre 1, sinon l’image renverra une 404.
Vérifier
Les quatre pages doivent répondre. Un test le garantit à chaque modification future. Le fichier complet, avec la méthode makePost() qui crée un article de test, est donné au chapitre 9, en voici la méthode qui protège les brouillons :
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 testLe test livré avec Laravel, tests/Feature/ExampleTest.php, charge la page d’accueil sans base de données : depuis que l’accueil interroge la table posts, il échoue avec no such table: posts. Ajoutez-lui le trait RefreshDatabase, comme dans les tests de ce tutoriel, ou supprimez-le.
Le chapitre suivant ajoute le moteur de recherche.