Capitolo 9 di 9

Tutorial Laravel 13 #9: paginazione e articoli correlati

verificato il 7 Settembre 2026 · 8 min

Risposta rapida

Sostituisci get() con paginate(5) e stampa {{ $posts->links() }}: Laravel 13 produce un rendering Tailwind accessibile senza configurazione. Per gli articoli correlati, filtra sui tag in comune con whereHas ed escludi l'articolo corrente con whereKeyNot.

Alla fine di questo capitolo, i tuoi elenchi saranno paginati e ogni articolo proporrà tre letture vicine.

Due rifiniture chiudono questa serie: spezzare gli elenchi troppo lunghi e proporre al lettore altri articoli alla fine di quello che ha appena letto.

La paginazione

È già attiva dal capitolo 7. Basta una parola alla fine della query:

php
// caricare tutto in una volta
$posts = Post::published()->latest('published_at')->get();

// caricare cinque articoli per pagina
$posts = Post::published()->latest('published_at')->paginate(5);

paginate() legge il parametro ?page= dell’URL, calcola il LIMIT e l’OFFSET ed esegue una query in più per contare il totale. L’oggetto restituito si percorre come una collection qualsiasi, con qualche metodo in più.

Aggiungi questa riga alla fine di partials/posts-list.blade.php:

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

Laravel 13 produce un rendering Tailwind predefinito, accessibile, con gli attributi aria-label e rel="next" corretti. Non serve nessuna configurazione.

La sintassi Bootstrap non ha più ragione di esistere

Le vecchie versioni di questo tutorial scrivevano {{ $posts->links("pagination::bootstrap-4") }}. Il template esiste ancora, ma presuppone Bootstrap 4 caricato nella pagina, cosa che non succede più da quando il progetto usa Tailwind. Scrivi links() senza argomento.

Siccome la vista parziale viene inclusa anche in pagine che ricevono una collection non paginata, proteggi la chiamata:

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

I metodi utili

php
$posts->total()        // numero totale di risultati, tutte le pagine insieme
$posts->currentPage()  // numero della pagina mostrata
$posts->lastPage()     // numero di pagine
$posts->hasPages()     // falso se tutto sta in una pagina

Non perdere i filtri

Nella pagina dei risultati del capitolo 8 i link di paginazione devono conservare il termine cercato. È il compito di withQueryString():

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

Senza questa chiamata, la pagina 2 di una ricerca mostra tutti gli articoli. È una dimenticanza frequente, che passa inosservata finché provi solo con pochi dati.

simplePaginate, quando il totale costa caro

paginate() esegue un COUNT(*) su tutta la tabella a ogni chiamata. Su tabelle molto voluminose questa query diventa il punto lento della pagina. simplePaginate() ne fa a meno e mostra solo «precedente» e «successivo», senza numeri di pagina né totale.

Gli articoli correlati

L’idea è semplice: proporre gli articoli che condividono almeno un tag con quello che si sta leggendo.

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

Tre elementi fanno il lavoro.

whereHas('tags', …) tiene solo gli articoli che hanno almeno un tag che soddisfa la condizione. La condizione è whereIn('tags.id', …): l’identificativo del tag deve comparire tra quelli dell’articolo corrente.

Il prefisso tags. davanti a id toglie ogni ambiguità. La sottoquery generata unisce tags e la tabella pivot 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 tabella pivot di questo tutorial, che non ha una colonna id, scrivere whereIn('id', …) produce esattamente la stessa query e funziona lo stesso. Prendi comunque l’abitudine di mettere il prefisso: il giorno in cui la tabella pivot avrà una sua chiave primaria, la colonna diventerà davvero ambigua.

whereKeyNot($post->getKey()) esclude l’articolo stesso, altrimenti comparirebbe sempre in cima ai propri suggerimenti, visto che condivide per forza tutti i suoi tag. Questo metodo equivale a where('id', '!=', $post->id) ma funziona qualunque sia il nome della chiave primaria.

La vista

Aggiungi questa sezione alla fine di post.blade.php, dopo il 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

L’@if evita di mostrare un titolo seguito dal nulla quando l’articolo non ha tag. strip_tags toglie l’HTML prima di troncare, altrimenti Str::limit potrebbe tagliare in mezzo a un tag.

I limiti di questo approccio

L’ordinamento segue l’ordine predefinito della tabella, non il numero di tag in comune: un articolo che ne condivide tre non passa davanti a uno che ne condivide uno solo. Un ordinamento per pertinenza richiederebbe di contare le corrispondenze nella tabella pivot, il che appesantisce parecchio la query.

Un articolo senza tag non ha mai suggerimenti. Un ripiego sulla categoria risolve il caso:

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

Verificare il tutto

Il progetto completo è coperto da una suite di test: le bozze restano invisibili, la paginazione taglia a cinque articoli, la ricerca trova quello che deve trovare e gli articoli simili condividono un tag. Ecco il file completo, con il metodo makePost() usato dal capitolo 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 » compare una sola volta: nella barra laterale, non tra i simili
        $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)

Come proseguire

Sei arrivato in fondo ai nove capitoli del tutorial Laravel 13. Hai un blog che funziona: articoli in ordine, un back-office per scriverli, una ricerca, una paginazione e un collegamento tra i contenuti. È la base su cui si costruisce più o meno qualsiasi applicazione Laravel.

Quello che questa serie non ha affrontato e che viene logicamente dopo: l’autenticazione dei visitatori, con gli starter kit di Laravel, i commenti, che aggiungono un’altra relazione «uno a molti», le code per le operazioni lunghe, e la messa online.

Su quest’ultimo punto, la guida installare Laravel su un server Debian spiega la procedura da seguire. Gli altri articoli su Laravel sono raccolti nell’hub Sviluppo web.

Errori frequenti

links("pagination::bootstrap-4") Questo template presuppone Bootstrap caricato nella pagina, cosa che non è più vera. Chiama links() senza argomento per ottenere il rendering Tailwind predefinito.
L'articolo corrente compare tra i propri suggerimenti Condivide per forza tutti i suoi tag con se stesso. whereKeyNot($post->getKey()) lo esclude.
Un articolo senza tag non ha mai suggerimenti Prevedi un ripiego sulla categoria.
La pagina 2 di un elenco filtrato perde il filtro withQueryString() conserva i parametri dell'URL nei link di paginazione.
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.