Rozdział 9 z 9

Kurs Laravel 13 #9: paginacja i lista podobnych artykułów

zweryfikowano 7 września 2026 · 9 min

Szybka odpowiedź

Zamień get() na paginate(5) i wyświetl {{ $posts->links() }}: Laravel 13 generuje dostępny szablon Tailwind bez żadnej konfiguracji. Podobne artykuły filtruj po wspólnych tagach przez whereHas, a bieżący artykuł wyklucz przez whereKeyNot.

Na koniec tego rozdziału, twoje listy będą podzielone na strony, a każdy artykuł zaproponuje trzy pokrewne lektury.

Serię zamykają dwa wykończenia: podzielenie zbyt długich list na strony i zaproponowanie czytelnikowi kolejnych artykułów na końcu tego, który właśnie przeczytał.

Paginacja

Jest na miejscu już od rozdziału 7. Uruchamia ją jedno słowo na końcu zapytania:

php
// wczytać wszystko naraz
$posts = Post::published()->latest('published_at')->get();

// wczytać pięć artykułów na stronę
$posts = Post::published()->latest('published_at')->paginate(5);

paginate() czyta parametr ?page= z adresu URL, wylicza LIMIT i OFFSET, a potem wykonuje dodatkowe zapytanie, żeby policzyć wszystkie wyniki. Zwrócony obiekt przechodzi się jak zwykłą kolekcję, tyle że ma kilka metod więcej.

Wyświetlenie linków

Dodaj tę linię na końcu partials/posts-list.blade.php:

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

Laravel 13 domyślnie generuje dostępny szablon Tailwind, z poprawnymi atrybutami aria-label i rel="next". Żadna konfiguracja nie jest potrzebna.

Składnia dla Bootstrapa nie ma już racji bytu

Wcześniejsze wersje tego kursu pisały {{ $posts->links("pagination::bootstrap-4") }}. Ten szablon nadal istnieje, ale zakłada Bootstrap 4 wczytany na stronie, a tak już nie jest, odkąd projekt korzysta z Tailwinda. Pisz links() bez argumentu.

Widok częściowy jest dołączany także na stronach, które dostają kolekcję bez paginacji, więc zabezpiecz to wywołanie:

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

Przydatne metody

php
$posts->total()        // łączna liczba wyników, ze wszystkich stron
$posts->currentPage()  // numer wyświetlanej strony
$posts->lastPage()     // liczba stron
$posts->hasPages()     // fałsz, jeśli wszystko mieści się na jednej stronie

Nie gubić filtrów

Na stronie wyników z rozdziału 8 linki paginacji muszą zachować szukaną frazę. Do tego służy withQueryString():

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

Bez tego wywołania druga strona wyszukiwania pokazuje wszystkie artykuły. To częste przeoczenie, niewidoczne dopóki testujesz na garstce danych.

simplePaginate, kiedy liczenie kosztuje

paginate() przy każdym wywołaniu wykonuje COUNT(*) na całej tabeli. Na bardzo dużych tabelach to zapytanie staje się wąskim gardłem strony. simplePaginate() obywa się bez niego i pokazuje tylko „poprzednia” i „następna”, bez numerów stron i bez sumy wyników.

Podobne artykuły

Pomysł jest prosty: proponować artykuły, które mają co najmniej jeden tag wspólny z tym, który czytelnik właśnie czyta.

Uzupełnij metodę show w 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(),
    ]);
}

Robotę wykonują trzy elementy.

whereHas('tags', …) zostawia tylko te artykuły, które mają przynajmniej jeden tag spełniający warunek. Warunkiem jest whereIn('tags.id', …): identyfikator tagu musi znaleźć się wśród tagów bieżącego artykułu.

Przedrostek tags. przed id usuwa wszelką dwuznaczność. Wygenerowane podzapytanie łączy tags z tabelą 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 (?, ?)
)

Przy tabeli pivot z tego kursu, która nie ma kolumny id, zapis whereIn('id', …) daje dokładnie to samo zapytanie i również działa. Mimo to wyrób sobie nawyk prefiksowania: w dniu, w którym tabela pivot dostanie własny klucz główny, kolumna stanie się naprawdę dwuznaczna.

whereKeyNot($post->getKey()) wyklucza sam artykuł, inaczej zawsze pojawiałby się na czele własnych propozycji, bo siłą rzeczy dzieli z sobą wszystkie tagi. Ta metoda odpowiada where('id', '!=', $post->id), ale działa niezależnie od nazwy klucza głównego.

Widok

Dodaj tę sekcję na końcu post.blade.php, za znacznikiem </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

@if zapobiega wyświetleniu nagłówka, pod którym nic nie ma, kiedy artykuł nie ma żadnego tagu. strip_tags usuwa HTML przed obcięciem, inaczej Str::limit mogłoby uciąć w środku znacznika.

Ograniczenia tego podejścia

Kolejność wynika z domyślnego porządku tabeli, a nie z liczby wspólnych tagów: artykuł mający trzy wspólne tagi nie wyprzedzi artykułu, który ma tylko jeden. Sortowanie po trafności wymagałoby liczenia dopasowań w tabeli pivot, co zauważalnie obciąża zapytanie.

Artykuł bez tagów nigdy nie dostanie propozycji. Zapasowe rozwiązanie oparte na kategorii załatwia sprawę:

php
$related = $post->tags->isEmpty()
    ? Post::published()->where('category_id', $post->category_id)->whereKeyNot($post->getKey())->take(3)->get()
    : /* zapytanie powyżej */;

Sprawdzenie całości

Cały projekt jest pokryty zestawem testów: szkice pozostają niewidoczne, paginacja tnie po pięciu artykułach, wyszukiwanie znajduje to, co powinno, a podobne artykuły dzielą etykietę. Oto kompletny plik, z metodą makePost() używaną od rozdziału 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” pojawia się tylko raz: w pasku bocznym, nie wśród podobnych
        $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)

Co dalej

Oto koniec dziewięciu rozdziałów kursu Laravel 13. Masz działającego bloga: uporządkowane artykuły, zaplecze do ich pisania, wyszukiwarkę, paginację i powiązania między treściami. To fundament, na którym buduje się właściwie dowolną aplikację w Laravelu.

Czego ta seria nie poruszyła, a co logicznie przychodzi dalej: uwierzytelnianie odwiedzających, z zestawami startowymi Laravela, komentarze, czyli kolejna relacja „jeden do wielu”, kolejki do długich zadań, oraz wdrożenie na serwer.

Ten ostatni punkt opisuje krok po kroku przewodnik instalacja Laravela na serwerze Debian. Pozostałe artykuły o Laravelu zebrane są w hubie Programowanie webowe.

Częste błędy

links("pagination::bootstrap-4") Ten szablon zakłada Bootstrap wczytany na stronie, a tak już nie jest. Wywołaj links() bez argumentu, żeby dostać domyślny szablon Tailwind.
Bieżący artykuł pojawia się we własnych propozycjach Siłą rzeczy dzieli z sobą wszystkie tagi. whereKeyNot($post->getKey()) go wyklucza.
Artykuł bez tagu nigdy nie dostaje propozycji Przewidź zapasowe rozwiązanie oparte na kategorii.
Druga strona filtrowanej listy gubi filtr withQueryString() zachowuje parametry adresu URL w linkach paginacji.
Newsletter

Nowe testy, poradniki i projekty, e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.