Chapitre 9 sur 9

Tutoriel Laravel 13 #9 : Pagination et articles similaires

vérifié le 7 septembre 2026 · 9 min

Réponse rapide

Remplacez get() par paginate(5) et affichez {{ $posts->links() }} : Laravel 13 produit un rendu Tailwind accessible sans configuration. Pour les articles similaires, filtrez sur les étiquettes communes avec whereHas et excluez l’article courant avec whereKeyNot.

À la fin de ce chapitre, vos listes seront paginées et chaque article proposera trois lectures voisines.

Deux finitions ferment cette série : découper les listes trop longues, et proposer au lecteur d’autres articles à la fin de celui qu’il vient de lire.

La pagination

Elle est déjà en place depuis le chapitre 7. Un seul mot la déclenche, à la fin de la requête :

php
// tout charger d'un coup
$posts = Post::published()->latest('published_at')->get();

// charger cinq articles par page
$posts = Post::published()->latest('published_at')->paginate(5);

paginate() lit le paramètre ?page= de l’URL, calcule le LIMIT et l’OFFSET, et fait une requête supplémentaire pour compter le total. L’objet renvoyé se parcourt comme une collection ordinaire, avec quelques méthodes en plus.

Afficher les liens

Ajoutez cette ligne à la fin de partials/posts-list.blade.php :

resources/views/partials/posts-list.blade.php
<div class="mt-6">{{ $posts->links() }}</div>

Laravel 13 produit un rendu Tailwind par défaut, accessible, avec les attributs aria-label et rel="next" corrects. Aucune configuration n’est nécessaire.

La syntaxe Bootstrap n’a plus lieu d’être

Les anciennes versions de ce tutoriel écrivaient {{ $posts->links("pagination::bootstrap-4") }}. Ce gabarit existe toujours, mais il suppose Bootstrap 4 chargé dans la page, ce qui n’est plus le cas depuis que le projet utilise Tailwind. Écrivez links() sans argument.

La vue partielle étant incluse aussi sur des pages qui reçoivent une collection non paginée, protégez l’appel :

markup
@if ($posts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator)
    <div class="mt-6">{{ $posts->links() }}</div>
@endif

Les méthodes utiles

php
$posts->total()        // nombre total de résultats, toutes pages confondues
$posts->currentPage()  // numéro de la page affichée
$posts->lastPage()     // nombre de pages
$posts->hasPages()     // faux si tout tient sur une page

Ne pas perdre les filtres

Sur la page de résultats du chapitre 8, les liens de pagination doivent conserver le terme recherché. C’est le rôle de withQueryString() :

php
->paginate(5)->withQueryString();

Sans cet appel, la page 2 d’une recherche affiche tous les articles. L’oubli est courant et passe inaperçu tant qu’on ne teste qu’avec peu de données.

simplePaginate, quand le total coûte cher

paginate() exécute un COUNT(*) sur toute la table à chaque appel. Sur des tables très volumineuses, cette requête devient le point lent de la page. simplePaginate() s’en passe et n’affiche que « précédent » et « suivant », sans numéros de page ni total.

Les articles similaires

L’idée est simple : proposer les articles qui partagent au moins une étiquette avec celui qu’on lit.

Complétez la méthode show du PostController :

app/Http/Controllers/PostController.php
public function show(Post $post): View
{
    abort_unless($post->is_published, 404);

    $post->load(['category', 'user', 'tags']);

    return view('post', [
        'post' => $post,
        'relatedPosts' => Post::published()
            ->whereHas('tags', fn ($query) => $query->whereIn('tags.id', $post->tags->pluck('id')))
            ->whereKeyNot($post->getKey())
            ->take(3)
            ->get(),
        'categories' => Category::withCount('posts')->orderBy('name')->get(),
        'tags' => Tag::orderBy('name')->get(),
        'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
    ]);
}

Trois éléments font le travail.

whereHas('tags', …) ne garde que les articles qui ont au moins une étiquette satisfaisant la condition. La condition est whereIn('tags.id', …) : l’identifiant de l’étiquette doit figurer parmi celles de l’article courant.

Le préfixe tags. devant id lève toute ambiguïté. La sous-requête générée joint tags et la table pivot post_tag :

sql
exists (
  select * from "tags"
  inner join "post_tag" on "tags"."id" = "post_tag"."tag_id"
  where "posts"."id" = "post_tag"."post_id" and "tags"."id" in (?, ?)
)

Avec la table pivot de ce tutoriel, qui n’a pas de colonne id, écrire whereIn('id', …) produit exactement la même requête et fonctionne aussi. Prenez malgré tout l’habitude de préfixer : le jour où la table pivot porte sa propre clé primaire, la colonne devient réellement ambiguë.

whereKeyNot($post->getKey()) exclut l’article lui-même, sinon il apparaîtrait toujours en tête de ses propres suggestions, puisqu’il partage forcément toutes ses étiquettes. Cette méthode équivaut à where('id', '!=', $post->id) mais fonctionne quel que soit le nom de la clé primaire.

La vue

Ajoutez cette section à la fin de post.blade.php, après la balise </article> :

resources/views/post.blade.php
@if ($relatedPosts->isNotEmpty())
    <section class="mt-8">
        <h2 class="mb-3 text-xl font-bold">Articles similaires</h2>
        <div class="grid gap-4 sm:grid-cols-3">
            @foreach ($relatedPosts as $related)
                <article class="rounded border bg-white p-4">
                    <h3 class="font-semibold">
                        <a href="{{ route('post', $related) }}" class="hover:underline">{{ $related->title }}</a>
                    </h3>
                    <p class="mt-1 text-sm text-gray-600">{{ Str::limit(strip_tags($related->content), 90) }}</p>
                </article>
            @endforeach
        </div>
    </section>
@endif

Le @if évite d’afficher un titre suivi de rien quand l’article n’a pas d’étiquette. strip_tags retire le HTML avant de tronquer, sinon Str::limit pourrait couper au milieu d’une balise.

Les limites de cette approche

Le classement se fait sur l’ordre par défaut de la table, pas sur le nombre d’étiquettes communes : un article partageant trois étiquettes ne passe pas devant un article n’en partageant qu’une. Un tri par pertinence demanderait de compter les correspondances dans la table pivot, ce qui alourdit sensiblement la requête.

Un article sans étiquette n’a jamais de suggestions. Un repli sur la catégorie règle le cas :

php
$related = $post->tags->isEmpty()
    ? Post::published()->where('category_id', $post->category_id)->whereKeyNot($post->getKey())->take(3)->get()
    : /* la requête ci-dessus */;

Vérifier l’ensemble

Le projet complet est couvert par une suite de tests : les brouillons restent invisibles, la pagination coupe bien à cinq articles, la recherche trouve ce qu’elle doit trouver, et les articles similaires partagent une étiquette. Voici le fichier complet, avec la méthode makePost() utilisée depuis le chapitre 7 :

tests/Feature/BlogTest.php
<?php

namespace Tests\Feature;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class BlogTest extends TestCase
{
    use RefreshDatabase;

    private function makePost(array $attributes = [], array $tags = []): Post
    {
        $user = User::first() ?? User::factory()->create();
        $category = Category::firstOrCreate(['slug' => 'laravel'], ['name' => 'Laravel']);

        $post = Post::create(array_merge([
            'category_id' => $category->id,
            'user_id' => $user->id,
            'title' => 'Premier article',
            'slug' => 'premier-article',
            'content' => '<p>Contenu</p>',
            'is_published' => true,
            'published_at' => now(),
        ], $attributes));

        foreach ($tags as $name) {
            $post->tags()->attach(Tag::firstOrCreate(['slug' => $name], ['name' => ucfirst($name)]));
        }

        return $post;
    }

    public function test_la_page_d_accueil_liste_les_articles_publies(): void
    {
        $this->makePost();

        $this->get('/')->assertOk()->assertSee('Premier article');
    }

    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();
    }

    public function test_la_page_categorie_repond(): void
    {
        $this->makePost();

        $this->get('/categorie/laravel')->assertOk()->assertSee('Premier article');
        $this->get('/categorie/inconnue')->assertNotFound();
    }

    public function test_la_page_etiquette_repond(): void
    {
        $this->makePost([], ['eloquent']);

        $this->get('/etiquette/eloquent')->assertOk()->assertSee('Premier article');
        $this->get('/etiquette/inconnue')->assertNotFound();
    }

    public function test_la_recherche_trouve_un_article_par_son_titre(): void
    {
        $this->makePost();
        $this->makePost(['title' => 'Autre chose', 'slug' => 'autre-chose']);

        $this->get('/recherche?q=Premier')->assertOk()->assertSee('Premier article')->assertSee('(1)');
    }

    public function test_la_recherche_sans_resultat_affiche_un_message(): void
    {
        $this->makePost();

        $this->get('/recherche?q=introuvable')->assertOk()->assertSee('Aucun article pour le moment.')->assertSee('(0)');
    }

    public function test_la_pagination_limite_a_cinq_articles(): void
    {
        foreach (range(1, 7) as $i) {
            $this->makePost(['title' => "Article {$i}", 'slug' => "article-{$i}", 'published_at' => now()->subDays($i)]);
        }

        $this->assertSame(5, substr_count($this->get('/')->getContent(), '<article'));
        $this->assertSame(2, substr_count($this->get('/?page=2')->getContent(), '<article'));
    }

    public function test_les_articles_similaires_partagent_une_etiquette(): void
    {
        $this->makePost(['title' => 'A', 'slug' => 'a'], ['eloquent']);
        $this->makePost(['title' => 'B partage', 'slug' => 'b'], ['eloquent']);
        $this->makePost(['title' => 'C isole', 'slug' => 'c'], ['vite']);

        $html = $this->get('/article/a')->assertOk()->assertSeeInOrder(['Articles similaires', 'B partage'])->getContent();

        // « C isole » n'apparaît qu'une fois : dans la barre latérale, pas dans les similaires
        $this->assertSame(1, substr_count($html, 'C isole'));
    }

    public function test_le_scope_published_filtre_correctement(): void
    {
        $this->makePost();
        $this->makePost(['slug' => 'brouillon', 'is_published' => false]);

        $this->assertSame(1, Post::published()->count());
        $this->assertSame(2, Post::count());
    }
}
bash
php artisan test
code
PASS  Tests\Unit\ExampleTest
✓ that true is true

PASS  Tests\Feature\AdminTest
✓ un email hors domaine est refuse
✓ un email du domaine est accepte

PASS  Tests\Feature\BlogTest
✓ la page d accueil liste les articles publies
✓ un article non publie est invisible
✓ la page categorie repond
✓ la page etiquette repond
✓ la recherche trouve un article par son titre
✓ la recherche sans resultat affiche un message
✓ la pagination limite a cinq articles
✓ les articles similaires partagent une etiquette
✓ le scope published filtre correctement

PASS  Tests\Feature\ExampleTest
✓ the application returns a successful response

Tests:    13 passed (28 assertions)

La suite

Vous voilà au bout des neuf chapitres du tutoriel Laravel 13. Vous avez un blog qui fonctionne : des articles rangés, un back-office pour les écrire, une recherche, une pagination et un maillage entre les contenus. C’est le socle sur lequel se construit à peu près n’importe quelle application Laravel.

Ce que cette série n’a pas abordé et qui vient logiquement ensuite : l’authentification des visiteurs, avec les kits de démarrage de Laravel, les commentaires, qui appliquent une relation « un à plusieurs » de plus, les files d’attente pour les tâches longues, et la mise en ligne.

Pour ce dernier point, le guide installer Laravel sur un serveur Debian détaille la marche à suivre. Les autres articles Laravel sont rassemblés dans le hub Développement web.

Erreurs fréquentes

links("pagination::bootstrap-4") Ce gabarit suppose Bootstrap chargé dans la page, ce qui n’est plus le cas. Appelez links() sans argument pour obtenir le rendu Tailwind par défaut.
L’article courant apparaît dans ses propres suggestions Il partage forcément toutes ses étiquettes avec lui-même. whereKeyNot($post->getKey()) l’exclut.
Un article sans étiquette n’a jamais de suggestions Prévoyez un repli sur la catégorie.
La page 2 d’une liste filtrée perd le filtre withQueryString() conserve les paramètres d’URL dans les liens de pagination.
Newsletter

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

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