Capítulo 9 de 9

Tutorial Laravel 13 #9: paginação e artigos semelhantes

verificado a 7 Setembro 2026 · 9 min

Resposta rápida

Substitui o get() por paginate(5) e mostra {{ $posts->links() }}: o Laravel 13 produz uma renderização Tailwind acessível sem qualquer configuração. Para os artigos semelhantes, filtra pelas etiquetas em comum com whereHas e exclui o artigo atual com whereKeyNot.

No fim deste capítulo, as tuas listas estarão paginadas e cada artigo proporá três leituras próximas.

Dois acabamentos fecham esta série: cortar as listas demasiado longas e propor ao leitor outros artigos no fim daquele que acabou de ler.

A paginação

Já está no lugar desde o capítulo 7. Uma única palavra a desencadeia, no fim da consulta:

php
// carregar tudo de uma vez
$posts = Post::published()->latest('published_at')->get();

// carregar cinco artigos por página
$posts = Post::published()->latest('published_at')->paginate(5);

O paginate() lê o parâmetro ?page= do URL, calcula o LIMIT e o OFFSET, e faz uma consulta adicional para contar o total. O objeto devolvido percorre-se como uma coleção normal, com alguns métodos a mais.

Acrescenta esta linha no fim de partials/posts-list.blade.php:

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

O Laravel 13 produz por omissão uma renderização Tailwind acessível, com os atributos aria-label e rel="next" corretos. Não é preciso qualquer configuração.

A sintaxe Bootstrap já não tem razão de ser

As versões antigas deste tutorial escreviam {{ $posts->links("pagination::bootstrap-4") }}. Esse template continua a existir, mas pressupõe o Bootstrap 4 carregado na página, o que já não acontece desde que o projeto usa Tailwind. Escreve links() sem argumento.

Como a vista parcial também é incluída em páginas que recebem uma coleção não paginada, protege a chamada:

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

Os métodos úteis

php
$posts->total()        // número total de resultados, todas as páginas juntas
$posts->currentPage()  // número da página apresentada
$posts->lastPage()     // número de páginas
$posts->hasPages()     // falso se tudo couber numa página

Não perder os filtros

Na página de resultados do capítulo 8, os links de paginação têm de conservar o termo pesquisado. É esse o papel do withQueryString():

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

Sem essa chamada, a página 2 de uma pesquisa mostra todos os artigos. O esquecimento é frequente e passa despercebido enquanto só se testa com poucos dados.

simplePaginate, quando o total sai caro

O paginate() executa um COUNT(*) sobre toda a tabela em cada chamada. Em tabelas muito volumosas, essa consulta torna-se o ponto lento da página. O simplePaginate() dispensa-a e mostra apenas «anterior» e «seguinte», sem números de página nem total.

Os artigos semelhantes

A ideia é simples: propor os artigos que partilham pelo menos uma etiqueta com aquele que se está a ler.

Completa o método show do 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(),
    ]);
}

Três elementos fazem o trabalho.

O whereHas('tags', …) só guarda os artigos que têm pelo menos uma etiqueta a satisfazer a condição. A condição é whereIn('tags.id', …): o identificador da etiqueta tem de constar entre as do artigo atual.

O prefixo tags. à frente de id elimina qualquer ambiguidade. A subconsulta gerada junta tags e a tabela pivô 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 (?, ?)
)

Com a tabela pivô deste tutorial, que não tem coluna id, escrever whereIn('id', …) produz exatamente a mesma consulta e também funciona. Ganha ainda assim o hábito de prefixar: no dia em que a tabela pivô tiver a sua própria chave primária, a coluna torna-se mesmo ambígua.

O whereKeyNot($post->getKey()) exclui o próprio artigo, caso contrário apareceria sempre à cabeça das suas próprias sugestões, já que partilha forçosamente todas as etiquetas consigo mesmo. Este método equivale a where('id', '!=', $post->id) mas funciona seja qual for o nome da chave primária.

A vista

Acrescenta esta secção no fim de post.blade.php, a seguir à tag </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

O @if evita mostrar um título seguido de nada quando o artigo não tem etiquetas. O strip_tags retira o HTML antes de truncar, senão o Str::limit podia cortar a meio de uma tag.

Os limites desta abordagem

A ordenação segue a ordem por omissão da tabela, não o número de etiquetas em comum: um artigo que partilhe três etiquetas não passa à frente de um que só partilhe uma. Uma ordenação por relevância obrigaria a contar as correspondências na tabela pivô, o que torna a consulta bastante mais pesada.

Um artigo sem etiquetas nunca tem sugestões. Um recuo para a categoria resolve o caso:

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

Verificar o conjunto

O projeto completo está coberto por uma suíte de testes: os rascunhos continuam invisíveis, a paginação corta em cinco artigos, a pesquisa encontra o que deve encontrar e os artigos semelhantes partilham uma etiqueta. Eis o ficheiro completo, com o método makePost() usado desde o capítulo 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 » aparece uma única vez: na barra lateral, não nos semelhantes
        $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)

A seguir

Chegaste ao fim dos nove capítulos do tutorial Laravel 13. Tens um blogue que funciona: artigos arrumados, um back-office para os escrever, uma pesquisa, uma paginação e uma malha de ligações entre os conteúdos. É a base sobre a qual se constrói praticamente qualquer aplicação Laravel.

O que esta série não abordou e que vem logicamente a seguir: a autenticação dos visitantes, com os kits de arranque do Laravel, os comentários, que aplicam mais uma relação «um para muitos», as filas de espera para as tarefas longas, e a colocação em produção.

Para este último ponto, o guia instalar o Laravel num servidor Debian detalha os passos a seguir. Os outros artigos sobre Laravel estão reunidos no hub Desenvolvimento web.

Erros frequentes

links("pagination::bootstrap-4") Este template pressupõe o Bootstrap carregado na página, o que já não acontece. Chama links() sem argumento para obteres a renderização Tailwind por omissão.
O artigo atual aparece nas suas próprias sugestões Partilha forçosamente todas as etiquetas consigo mesmo. O whereKeyNot($post->getKey()) exclui-o.
Um artigo sem etiquetas nunca tem sugestões Prevê um recuo para a categoria.
A página 2 de uma lista filtrada perde o filtro O withQueryString() conserva os parâmetros do URL nos links de paginação.
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.