Chapitre 7 sur 9

Tutoriel Laravel 13 #7 : Créer des routes, des contrôleurs et des vues

vérifié le 7 septembre 2026 · 8 min

Réponse rapide

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.

À la fin de ce chapitre, les pages d’accueil, de catégorie, d’étiquette et d’article afficheront vos données, sans requête superflue.

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.

bash
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostController

Cré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

routes/web.php
<?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.

L’ordre des routes compte

/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

app/Http/Controllers/IndexController.php
<?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 remplace where('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 sur created_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 colonne posts_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 :

sql
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

app/Http/Controllers/CategoryController.php
<?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 :

app/Http/Controllers/TagController.php
<?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

app/Http/Controllers/PostController.php
<?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.

resources/views/category.blade.php
@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
resources/views/tag.blade.php
@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')
@endsection

La fiche d’un article

resources/views/post.blade.php
@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 :

tests/Feature/BlogTest.php
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();
}
bash
php artisan test
ExampleTest échoue dès ce chapitre

Le 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.

Erreurs fréquentes

Un brouillon reste visible par son adresse La liaison de modèle trouve l’article publié ou non. Ajoutez abort_unless($post->is_published, 404), sinon vos brouillons sont lisibles de qui devine l’URL.
Oublier les parenthèses sur une relation $category->posts renvoie la collection complète, impossible à filtrer ni à paginer. $category->posts() renvoie une requête que l’on peut affiner.
La page ralentit à mesure qu’elle grandit C’est le problème des requêtes N+1. with() charge les relations d’avance et fige le nombre de requêtes.
latest() trie sur la mauvaise colonne Sans argument, latest() trie sur created_at. Pour un blog, c’est published_at qu’il faut passer.
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.