Capítulo 9 de 9

Tutorial de Laravel 13 #9: paginación y artículos relacionados

verificado el 7 septiembre 2026 · 9 min

Respuesta rápida

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.

Al final de este capítulo, tus listas estarán paginadas y cada artículo propondrá tres lecturas cercanas.

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:

php
// 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:

resources/views/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.

La sintaxis Bootstrap ya no tiene sentido

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:

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

Los métodos útiles

php
$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ágina

No 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():

php
->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:

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(),
    ]);
}

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:

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

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>:

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

El @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:

php
$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:

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 » 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());
    }
}
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)

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.

Errores frecuentes

links("pagination::bootstrap-4") Esa plantilla da por hecho que Bootstrap está cargado en la página, y ya no es el caso. Llama a links() sin argumentos para obtener el renderizado Tailwind por defecto.
El artículo actual aparece entre sus propias sugerencias Comparte forzosamente todas sus etiquetas consigo mismo. whereKeyNot($post->getKey()) lo excluye.
Un artículo sin etiquetas nunca tiene sugerencias Prevé un plan B basado en la categoría.
La página 2 de una lista filtrada pierde el filtro withQueryString() conserva los parámetros de URL en los enlaces de paginación.
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.