Hoofdstuk 9 van 9

Laravel 13-tutorial #9: paginatie en gerelateerde artikelen

geverifieerd op 7 september 2026 · 9 min

Kort antwoord

Vervang get() door paginate(5) en toon {{ $posts->links() }}: Laravel 13 levert zonder configuratie een toegankelijke Tailwind-weergave. Filter gerelateerde artikelen op gedeelde tags met whereHas en sluit het huidige artikel uit met whereKeyNot.

Aan het einde van dit hoofdstuk, zijn je lijsten gepagineerd en stelt elk artikel drie verwante lezingen voor.

Twee afwerkingen sluiten deze reeks af: te lange lijsten opdelen, en de lezer aan het einde van het artikel dat hij net las andere artikelen voorstellen.

De paginatie

Die staat al klaar sinds hoofdstuk 7. Eén woord aan het einde van de query zet haar in gang:

php
// alles in één keer laden
$posts = Post::published()->latest('published_at')->get();

// vijf artikelen per pagina laden
$posts = Post::published()->latest('published_at')->paginate(5);

paginate() leest de parameter ?page= uit de URL, berekent de LIMIT en de OFFSET, en voert een extra query uit om het totaal te tellen. Het teruggegeven object doorloop je als een gewone collectie, met een paar methodes extra.

Voeg deze regel toe aan het einde van partials/posts-list.blade.php:

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

Laravel 13 levert standaard een toegankelijke Tailwind-weergave op, met de juiste attributen aria-label en rel="next". Configureren hoef je niets.

De Bootstrap-syntaxis heeft geen bestaansreden meer

Oudere versies van deze tutorial schreven {{ $posts->links("pagination::bootstrap-4") }}. Dat sjabloon bestaat nog, maar het gaat ervan uit dat Bootstrap 4 in de pagina geladen is, en dat is niet meer zo sinds het project Tailwind gebruikt. Schrijf links() zonder argument.

Omdat de partial ook wordt ingesloten op pagina’s die een niet-gepagineerde collectie krijgen, moet je de aanroep afschermen:

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

De nuttige methodes

php
$posts->total()        // totaal aantal resultaten, alle pagina's samen
$posts->currentPage()  // nummer van de getoonde pagina
$posts->lastPage()     // aantal pagina's
$posts->hasPages()     // false als alles op één pagina past

De filters niet kwijtraken

Op de resultatenpagina uit hoofdstuk 8 moeten de paginatielinks de zoekterm behouden. Dat is de taak van withQueryString():

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

Zonder die aanroep toont pagina 2 van een zoekopdracht alle artikelen. Het is een veelgemaakte vergissing die onopgemerkt blijft zolang je met weinig data test.

simplePaginate, als het totaal duur wordt

paginate() voert bij elke aanroep een COUNT(*) uit over de hele tabel. Op zeer grote tabellen wordt die query het trage punt van de pagina. simplePaginate() doet het zonder en toont alleen ‘vorige’ en ‘volgende’, zonder paginanummers en zonder totaal.

De gerelateerde artikelen

Het idee is eenvoudig: de artikelen voorstellen die minstens één tag delen met het artikel dat wordt gelezen.

Vul de methode show van de PostController aan:

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

Drie elementen doen het werk.

whereHas('tags', …) houdt alleen de artikelen over die minstens één tag hebben die aan de voorwaarde voldoet. Die voorwaarde is whereIn('tags.id', …): de id van de tag moet voorkomen tussen die van het huidige artikel.

De prefix tags. voor id neemt elke dubbelzinnigheid weg. De gegenereerde subquery joint tags met de pivottabel 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 (?, ?)
)

Met de pivottabel van deze tutorial, die geen kolom id heeft, levert whereIn('id', …) precies dezelfde query op en werkt het net zo goed. Wen je toch aan het prefixen: zodra de pivottabel een eigen primaire sleutel krijgt, wordt de kolom echt dubbelzinnig.

whereKeyNot($post->getKey()) sluit het artikel zelf uit, anders zou het altijd bovenaan zijn eigen suggesties staan, want het deelt noodzakelijkerwijs al zijn tags. Die methode komt neer op where('id', '!=', $post->id), maar werkt ongeacht de naam van de primaire sleutel.

De view

Voeg deze sectie toe aan het einde van post.blade.php, na de afsluitende </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="http://%20route('post',%20$related)%20" 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

De @if voorkomt dat er een kop verschijnt met niets eronder wanneer het artikel geen tag heeft. strip_tags haalt de HTML weg voor het afkappen, anders zou Str::limit midden in een element kunnen snijden.

De grenzen van deze aanpak

De volgorde volgt de standaardsortering van de tabel, niet het aantal gedeelde tags: een artikel dat drie tags deelt komt niet voor een artikel dat er maar één deelt. Sorteren op relevantie zou betekenen dat je de overeenkomsten in de pivottabel telt, en dat verzwaart de query merkbaar.

Een artikel zonder tag krijgt nooit suggesties. Een terugval op de categorie lost dat op:

php
$related = $post->tags->isEmpty()
    ? Post::published()->where('category_id', $post->category_id)->whereKeyNot($post->getKey())->take(3)->get()
    : /* de query hierboven */;

Het geheel controleren

Het volledige project is gedekt door een testsuite: concepten blijven onzichtbaar, de paginering knipt af bij vijf artikelen, de zoekfunctie vindt wat ze moet vinden en vergelijkbare artikelen delen een tag. Hier is het volledige bestand, met de methode makePost() die sinds hoofdstuk 7 wordt gebruikt:

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’ komt maar één keer voor: in de zijbalk, niet bij de vergelijkbare artikelen
        $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)

Hoe nu verder

Je bent aan het einde van de negen hoofdstukken van de Laravel 13-tutorial. Je hebt een werkende blog: geordende artikelen, een back-office om ze te schrijven, een zoekfunctie, paginatie en onderlinge links tussen de content. Dat is het fundament waarop zowat elke Laravel-applicatie wordt gebouwd.

Wat deze reeks niet heeft behandeld en wat er logisch op volgt: de authenticatie van bezoekers, met de starter kits van Laravel, reacties, nog een relatie ‘één op veel’, queues voor langlopende taken, en het live zetten.

Voor dat laatste punt beschrijft de gids Laravel installeren op een Debian-server stap voor stap wat je moet doen. De andere Laravel-artikelen staan bij elkaar in de hub Webontwikkeling.

Veelgemaakte fouten

links("pagination::bootstrap-4") Dat sjabloon gaat ervan uit dat Bootstrap in de pagina geladen is, en dat is niet meer zo. Roep links() zonder argument aan voor de standaard Tailwind-weergave.
Het huidige artikel verschijnt in zijn eigen suggesties Het deelt noodzakelijkerwijs al zijn tags met zichzelf. whereKeyNot($post->getKey()) sluit het uit.
Een artikel zonder tag krijgt nooit suggesties Voorzie een terugval op de categorie.
Pagina 2 van een gefilterde lijst verliest het filter withQueryString() behoudt de URL-parameters in de paginatielinks.
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.