Chapter 9 of 9

Laravel 13 tutorial #9: pagination and related posts

verified on 7 September 2026 · 9 min

Quick answer

Replace get() with paginate(5) and render {{ $posts->links() }}: Laravel 13 produces accessible Tailwind markup with no configuration at all. For related posts, filter on the tags they have in common with whereHas and exclude the current post with whereKeyNot.

By the end of this chapter, your lists will be paginated and every post will point to three neighbouring reads.

Two finishing touches close this series: breaking up lists that have grown too long, and offering the reader other posts at the end of the one they have just read.

Pagination

It has been in place since chapter 7. A single word triggers it, at the end of the query:

php
// load everything at once
$posts = Post::published()->latest('published_at')->get();

// load five posts per page
$posts = Post::published()->latest('published_at')->paginate(5);

paginate() reads the ?page= parameter from the URL, works out the LIMIT and the OFFSET, and runs one extra query to count the total. The object it returns is iterated like an ordinary collection, with a few extra methods.

Add this line at the end of partials/posts-list.blade.php:

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

Laravel 13 renders Tailwind markup by default, accessible, with the right aria-label and rel="next" attributes. No configuration is needed.

The Bootstrap syntax no longer has a reason to exist

Earlier versions of this tutorial wrote {{ $posts->links("pagination::bootstrap-4") }}. That template still exists, but it assumes Bootstrap 4 is loaded in the page, which stopped being true when the project moved to Tailwind. Write links() with no argument.

Since the partial view is also included on pages that receive an unpaginated collection, guard the call:

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

The useful methods

php
$posts->total()        // total number of results, across all pages
$posts->currentPage()  // number of the page being shown
$posts->lastPage()     // number of pages
$posts->hasPages()     // false if everything fits on one page

Not losing the filters

On the results page from chapter 8, the pagination links have to keep the search term. That is what withQueryString() is for:

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

Without that call, page 2 of a search lists every post. Forgetting it is common, and it goes unnoticed as long as you only test with a handful of rows.

simplePaginate, when the total costs too much

paginate() runs a COUNT(*) over the whole table on every call. On very large tables, that query becomes the slow point of the page. simplePaginate() does without it and only shows “previous” and “next”, with no page numbers and no total.

The idea is simple: offer the posts that share at least one tag with the one being read.

Fill in the show method of 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(),
    ]);
}

Three pieces do the work.

whereHas('tags', …) keeps only the posts that have at least one tag matching the condition. The condition is whereIn('tags.id', …): the tag id has to be one of those on the current post.

The tags. prefix in front of id removes any ambiguity. The generated subquery joins tags and the post_tag pivot table:

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 (?, ?)
)

With this tutorial’s pivot table, which has no id column, writing whereIn('id', …) produces exactly the same query and works too. Get into the habit of prefixing all the same: the day the pivot table carries its own primary key, the column really does become ambiguous.

whereKeyNot($post->getKey()) excludes the post itself, otherwise it would always sit at the top of its own suggestions, since it necessarily shares every one of its tags. The method is equivalent to where('id', '!=', $post->id), but it works whatever the primary key is called.

The view

Add this section at the end of post.blade.php, after the </article> tag:

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

The @if avoids showing a heading followed by nothing when the post has no tag. strip_tags removes the HTML before truncating, otherwise Str::limit could cut in the middle of a tag.

The limits of this approach

Ordering follows the table’s default order, not the number of tags in common: a post sharing three tags does not come ahead of a post sharing only one. Sorting by relevance would mean counting the matches in the pivot table, which makes the query noticeably heavier.

A post with no tag never gets any suggestions. Falling back on the category settles that case:

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

Checking the whole thing

The complete project is covered by a test suite: drafts stay invisible, pagination cuts at five posts, the search finds what it should, and related posts share a tag. Here is the complete file, with the makePost() method used since chapter 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' appears only once: in the sidebar, not among the related posts
        $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)

Where to go next

That is the end of the nine chapters of the Laravel 13 tutorial. You have a blog that works: posts in order, a back office to write them in, a search, pagination and links between the contents. It is the base almost any Laravel application is built on.

What this series did not cover and comes logically next: authenticating visitors, with Laravel’s starter kits, comments, which apply one more “one to many” relationship, queues for long-running jobs, and going live.

On that last point, the guide installing Laravel on a Debian server spells out the procedure. The other Laravel posts are gathered in the Web development hub.

Common errors

links("pagination::bootstrap-4") That template assumes Bootstrap is loaded in the page, which is no longer the case. Call links() with no argument to get the default Tailwind markup.
The current post shows up in its own suggestions It necessarily shares every one of its tags with itself. whereKeyNot($post->getKey()) excludes it.
A post with no tag never gets suggestions Plan a fallback on the category.
Page 2 of a filtered list loses the filter withQueryString() keeps the URL parameters in the pagination links.
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.