Tutorial Laravel 13 #9: paginação e artigos semelhantes
verificado a 7 Setembro 2026 · 9 min
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.
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:
// 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.
Mostrar os links
Acrescenta esta linha no fim de 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.
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:
@if ($posts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator)
<div class="mt-6">{{ $posts->links() }}</div>
@endifOs métodos úteis
$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áginaNã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():
->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:
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:
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>:
@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>
@endifO @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:
$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:
<?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());
}
}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)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.