Tutorial de Laravel 13 #9: paginación y artículos relacionados
verificado el 7 septiembre 2026 · 9 min
Sustituye get() por paginate(5) y muestra {{ $posts->links() }}: Laravel 13 genera un renderizado Tailwind accesible sin configurar nada. Para los artículos relacionados, filtra por las etiquetas comunes con whereHas y excluye el artículo actual con whereKeyNot.
Dos acabados cierran la serie: partir las listas demasiado largas y ofrecer al lector otros artículos al final del que acaba de leer.
La paginación
Ya está en su sitio desde el capítulo 7. Una sola palabra la activa, al final de la consulta:
// cargarlo todo de golpe
$posts = Post::published()->latest('published_at')->get();
// cargar cinco artículos por página
$posts = Post::published()->latest('published_at')->paginate(5);paginate() lee el parámetro ?page= de la URL, calcula el LIMIT y el OFFSET, y lanza una consulta adicional para contar el total. El objeto devuelto se recorre como una colección normal, con algunos métodos de más.
Mostrar los enlaces
Añade esta línea al final de partials/posts-list.blade.php:
<div class="mt-6">{{ $posts->links() }}</div>Laravel 13 genera por defecto un renderizado Tailwind accesible, con los atributos aria-label y rel="next" correctos. No hace falta configurar nada.
Las versiones antiguas de este tutorial escribían {{ $posts->links("pagination::bootstrap-4") }}. Esa plantilla sigue existiendo, pero da por hecho que Bootstrap 4 está cargado en la página, y ya no es el caso desde que el proyecto usa Tailwind. Escribe links() sin argumentos.
Como la vista parcial también se incluye en páginas que reciben una colección sin paginar, protege la llamada:
@if ($posts instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator)
<div class="mt-6">{{ $posts->links() }}</div>
@endifLos métodos útiles
$posts->total() // número total de resultados, sumando todas las páginas
$posts->currentPage() // número de la página mostrada
$posts->lastPage() // número de páginas
$posts->hasPages() // falso si todo cabe en una páginaNo perder los filtros
En la página de resultados del capítulo 8, los enlaces de paginación tienen que conservar el término buscado. De eso se encarga withQueryString():
->paginate(5)->withQueryString();Sin esa llamada, la página 2 de una búsqueda muestra todos los artículos. Es un olvido habitual y pasa desapercibido mientras solo pruebes con pocos datos.
simplePaginate, cuando el total sale caro
paginate() ejecuta un COUNT(*) sobre toda la tabla en cada llamada. En tablas muy grandes, esa consulta se convierte en el punto lento de la página. simplePaginate() prescinde de ella y solo muestra «anterior» y «siguiente», sin números de página ni total.
Los artículos relacionados
La idea es sencilla: proponer los artículos que comparten al menos una etiqueta con el que se está leyendo.
Completa el método show del 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(),
]);
}El trabajo lo hacen tres elementos.
whereHas('tags', …) solo se queda con los artículos que tienen al menos una etiqueta que cumple la condición. La condición es whereIn('tags.id', …): el identificador de la etiqueta debe estar entre las del artículo actual.
El prefijo tags. delante de id elimina cualquier ambigüedad. La subconsulta generada une tags y la tabla pivote 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 (?, ?)
)Con la tabla pivote de este tutorial, que no tiene columna id, escribir whereIn('id', …) produce exactamente la misma consulta y también funciona. Aun así, acostúmbrate a poner el prefijo: el día en que la tabla pivote tenga su propia clave primaria, la columna pasa a ser realmente ambigua.
whereKeyNot($post->getKey()) excluye al propio artículo: si no, siempre encabezaría sus propias sugerencias, porque comparte forzosamente todas sus etiquetas. Ese método equivale a where('id', '!=', $post->id), pero funciona se llame como se llame la clave primaria.
La vista
Añade esta sección al final de post.blade.php, después de la etiqueta </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>
@endifEl @if evita mostrar un título seguido de nada cuando el artículo no tiene etiquetas. strip_tags quita el HTML antes de truncar, si no, Str::limit podría cortar en mitad de una etiqueta.
Los límites de este enfoque
La ordenación sigue el orden por defecto de la tabla, no el número de etiquetas comunes: un artículo que comparte tres etiquetas no adelanta a otro que solo comparte una. Ordenar por relevancia obligaría a contar las coincidencias en la tabla pivote, lo que recarga bastante la consulta.
Un artículo sin etiquetas nunca tiene sugerencias. Un plan B basado en la categoría resuelve el caso:
$related = $post->tags->isEmpty()
? Post::published()->where('category_id', $post->category_id)->whereKeyNot($post->getKey())->take(3)->get()
: /* la consulta de arriba */;Comprobarlo todo
El proyecto completo está cubierto por una suite de pruebas: los borradores permanecen invisibles, la paginación corta en cinco artículos, la búsqueda encuentra lo que debe encontrar y los artículos similares comparten una etiqueta. Este es el archivo completo, con el método makePost() usado desde el 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 » solo aparece una vez: en la barra lateral, no entre los similares
$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)Lo que viene ahora
Aquí acaban los nueve capítulos del tutorial de Laravel 13. Tienes un blog que funciona: artículos ordenados, un back-office para escribirlos, una búsqueda, una paginación y enlaces entre los contenidos. Es la base sobre la que se construye prácticamente cualquier aplicación Laravel.
Lo que esta serie no ha tratado y viene después de forma natural: la autenticación de los visitantes, con los starter kits de Laravel, los comentarios, que aplican una relación «uno a muchos» más, las colas para las tareas largas, y la puesta en producción.
Para esto último, la guía instalar Laravel en un servidor Debian detalla el procedimiento. El resto de artículos sobre Laravel están reunidos en el hub Desarrollo web.