Kapitel 9 von 9

Laravel-13-Tutorial #9: Pagination und ähnliche Artikel

geprüft am 7 September 2026 · 9 Min.

Schnelle Antwort

Ersetz get() durch paginate(5) und gib {{ $posts->links() }} aus: Laravel 13 erzeugt ohne Konfiguration eine barrierefreie Tailwind-Ausgabe. Für die ähnlichen Artikel filterst du mit whereHas auf gemeinsame Tags und schließt den aktuellen Artikel mit whereKeyNot aus.

Am Ende dieses Kapitels, sind deine Listen paginiert und jeder Artikel schlägt drei passende Lektüren vor.

Zwei Feinheiten schließen diese Serie ab: zu lange Listen aufteilen und dem Leser am Ende des gerade gelesenen Artikels weitere Artikel anbieten.

Die Pagination

Sie steht seit Kapitel 7 schon bereit. Ein einziges Wort am Ende der Abfrage schaltet sie ein:

php
// alles auf einmal laden
$posts = Post::published()->latest('published_at')->get();

// fünf Artikel pro Seite laden
$posts = Post::published()->latest('published_at')->paginate(5);

paginate() liest den Parameter ?page= aus der URL, berechnet LIMIT und OFFSET und setzt eine zusätzliche Abfrage ab, um die Gesamtzahl zu ermitteln. Das zurückgegebene Objekt durchläufst du wie eine gewöhnliche Collection, nur mit ein paar Methoden mehr.

Füge diese Zeile am Ende von partials/posts-list.blade.php ein:

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

Laravel 13 erzeugt standardmäßig eine barrierefreie Tailwind-Ausgabe, mit korrekten Attributen aria-label und rel="next". Konfiguration ist keine nötig.

Die Bootstrap-Syntax hat ausgedient

Ältere Fassungen dieses Tutorials schrieben {{ $posts->links("pagination::bootstrap-4") }}. Diese Vorlage gibt es weiterhin, sie setzt aber Bootstrap 4 in der Seite voraus, und das ist nicht mehr der Fall, seit das Projekt Tailwind verwendet. Schreib links() ohne Argument.

Da die Teilansicht auch auf Seiten eingebunden wird, die eine nicht paginierte Collection bekommen, sicher den Aufruf ab:

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

Die nützlichen Methoden

php
$posts->total()        // Gesamtzahl der Ergebnisse über alle Seiten
$posts->currentPage()  // Nummer der angezeigten Seite
$posts->lastPage()     // Anzahl der Seiten
$posts->hasPages()     // false, wenn alles auf eine Seite passt

Die Filter nicht verlieren

Auf der Ergebnisseite aus Kapitel 8 müssen die Pagination-Links den Suchbegriff behalten. Genau dafür ist withQueryString() da:

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

Ohne diesen Aufruf zeigt Seite 2 einer Suche sämtliche Artikel. Der Fehler passiert oft und fällt nicht auf, solange man nur mit wenigen Daten testet.

simplePaginate, wenn die Gesamtzahl teuer wird

paginate() führt bei jedem Aufruf ein COUNT(*) über die gesamte Tabelle aus. Bei sehr großen Tabellen wird diese Abfrage zur Bremse der Seite. simplePaginate() kommt ohne sie aus und zeigt nur „zurück“ und „weiter“, ohne Seitenzahlen und ohne Gesamtzahl.

Ähnliche Artikel

Die Idee ist simpel: die Artikel vorschlagen, die mindestens ein Tag mit dem gerade gelesenen teilen.

Ergänze die Methode show im 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(),
    ]);
}

Drei Bausteine erledigen die Arbeit.

whereHas('tags', …) behält nur die Artikel, die mindestens ein Tag haben, das die Bedingung erfüllt. Die Bedingung lautet whereIn('tags.id', …): die ID des Tags muss unter denen des aktuellen Artikels sein.

Das Präfix tags. vor id beseitigt jede Mehrdeutigkeit. Die erzeugte Unterabfrage verbindet tags mit der Pivot-Tabelle 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 (?, ?)
)

Bei der Pivot-Tabelle dieses Tutorials, die keine Spalte id hat, erzeugt whereIn('id', …) exakt dieselbe Abfrage und funktioniert ebenso. Gewöhn dir das Präfix trotzdem an: sobald die Pivot-Tabelle einen eigenen Primärschlüssel bekommt, wird die Spalte wirklich mehrdeutig.

whereKeyNot($post->getKey()) schließt den Artikel selbst aus – sonst stünde er immer an der Spitze seiner eigenen Vorschläge, denn er teilt zwangsläufig alle seine Tags mit sich. Die Methode entspricht where('id', '!=', $post->id), funktioniert aber unabhängig vom Namen des Primärschlüssels.

Die View

Füge diesen Abschnitt am Ende von post.blade.php ein, hinter dem schließenden </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

Das @if verhindert eine Überschrift, unter der nichts steht, wenn der Artikel kein Tag hat. strip_tags entfernt das HTML vor dem Kürzen, sonst könnte Str::limit mitten in einem HTML-Tag abschneiden.

Die Grenzen dieses Ansatzes

Sortiert wird nach der Standardreihenfolge der Tabelle, nicht nach der Zahl gemeinsamer Tags: ein Artikel mit drei gemeinsamen Tags landet nicht vor einem Artikel mit nur einem. Eine Sortierung nach Relevanz würde verlangen, die Treffer in der Pivot-Tabelle zu zählen, was die Abfrage spürbar schwerer macht.

Ein Artikel ohne Tag bekommt nie Vorschläge. Ein Rückgriff auf die Kategorie löst den Fall:

php
$related = $post->tags->isEmpty()
    ? Post::published()->where('category_id', $post->category_id)->whereKeyNot($post->getKey())->take(3)->get()
    : /* die Abfrage von oben */;

Das Ganze prüfen

Das komplette Projekt ist durch eine Testsuite abgedeckt: Entwürfe bleiben unsichtbar, die Paginierung schneidet bei fünf Artikeln ab, die Suche findet, was sie finden soll, und ähnliche Artikel teilen ein Tag. Hier die vollständige Datei mit der seit Kapitel 7 verwendeten Methode makePost():

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“ erscheint nur einmal: in der Seitenleiste, nicht bei den ähnlichen Artikeln
        $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)

Wie es weitergeht

Damit sind die neun Kapitel des Laravel-13-Tutorials durch. Du hast ein funktionierendes Blog: sortierte Artikel, ein Backoffice zum Schreiben, eine Suche, eine Pagination und eine Verlinkung zwischen den Inhalten. Das ist das Fundament, auf dem sich so ziemlich jede Laravel-Anwendung aufbaut.

Was diese Serie nicht behandelt hat und logisch als Nächstes kommt: die Authentifizierung der Besucher mit den Starter-Kits von Laravel, die Kommentare, die eine weitere Beziehung „eins zu viele“ mitbringen, die Queues für lange Aufgaben, und der Livegang.

Für den letzten Punkt beschreibt der Leitfaden Laravel auf einem Debian-Server installieren das Vorgehen im Detail. Die übrigen Laravel-Artikel sind im Hub Webentwicklung gesammelt.

Häufige Fehler

links("pagination::bootstrap-4") Diese Vorlage setzt Bootstrap in der Seite voraus, was nicht mehr der Fall ist. Ruf links() ohne Argument auf, um die Tailwind-Ausgabe als Standard zu bekommen.
Der aktuelle Artikel taucht in seinen eigenen Vorschlägen auf Er teilt zwangsläufig alle seine Tags mit sich selbst. whereKeyNot($post->getKey()) schließt ihn aus.
Ein Artikel ohne Tag bekommt nie Vorschläge Sieh einen Rückgriff auf die Kategorie vor.
Seite 2 einer gefilterten Liste verliert den Filter withQueryString() behält die URL-Parameter in den Pagination-Links.
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.