Kurs Laravel 13 #9: paginacja i lista podobnych artykułów
zweryfikowano 7 września 2026 · 9 min
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.
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:
// 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:
<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.
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:
@if ($posts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator)
<div class="mt-6">{{ $posts->links() }}</div>
@endifPrzydatne metody
$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 stronieNie gubić filtrów
Na stronie wyników z rozdziału 8 linki paginacji muszą zachować szukaną frazę. Do tego służy withQueryString():
->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:
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:
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>:
@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ę:
$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:
<?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());
}
}php artisan testPASS 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.