Capitolo 7 di 9

Tutorial Laravel 13 #7: creare rotte, controller e viste

verificato il 7 Settembre 2026 · 8 min

Risposta rapida

Ogni pagina riceve un controller che interroga i modelli e restituisce una vista. Aggiungi sempre with(['category', 'user']) agli elenchi: su cinque articoli, l'eager loading fa passare la pagina da 11 query a 3.

Alla fine di questo capitolo, le pagine home, categoria, tag e articolo mostreranno i tuoi dati, senza nemmeno una query superflua.

I dati esistono e il back-office permette di inserirli. Questo capitolo li mostra: la home page, le pagine di categoria e di tag, e la scheda di un articolo.

Creare i controller

Tre controller hanno una sola cosa da fare, da cui --invokable. IndexController esiste già dal capitolo 5: il suo contenuto viene sostituito più in basso. PostController avrà due metodi: mostrare un articolo e gestire la ricerca nel capitolo seguente.

bash
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostController

Createli prima di scrivere le rotte: una rotta della forma Route::get('/…', CategoryController::class) esige che la classe esista, altrimenti nessun comando artisan parte più.

Le rotte

routes/web.php
<?php

use App\Http\Controllers\CategoryController;
use App\Http\Controllers\IndexController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\TagController;
use Illuminate\Support\Facades\Route;

Route::get('/', IndexController::class)->name('home');
Route::get('/recherche', [PostController::class, 'search'])->name('search');
Route::get('/categorie/{category}', CategoryController::class)->name('category');
Route::get('/etiquette/{tag}', TagController::class)->name('tag');
Route::get('/article/{post}', [PostController::class, 'show'])->name('post');

Ogni rotta ha un nome: le viste costruiscono i loro link con route('post', $post), mai con un indirizzo scritto a mano.

Grazie a getRouteKeyName(), dichiarato sui modelli nel capitolo 6, i parametri {category}, {tag} e {post} vengono risolti sulla colonna slug. Laravel carica il record e restituisce un 404 se non corrisponde nulla, ancora prima di entrare nel controller.

L’ordine delle rotte conta

/recherche è dichiarata prima delle rotte con parametro. Se avessi scritto Route::get('/{slug}', …) più in alto, catturerebbe /recherche e cercherebbe un articolo con quello slug. Laravel si ferma alla prima rotta che corrisponde: gli indirizzi fissi vanno prima di quelli variabili.

Il controller della home page

app/Http/Controllers/IndexController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class IndexController extends Controller
{
    public function __invoke(): View
    {
        return view('home', [
            'posts' => Post::published()
                ->with(['category', 'user'])
                ->latest('published_at')
                ->paginate(5),
            'featuredPosts' => Post::published()->featured()
                ->latest('published_at')
                ->take(5)
                ->get(),
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

Quattro metodi meritano una spiegazione.

  • paginate(5) divide l’elenco in pagine da cinque articoli. Il suo funzionamento e la stampa dei link sono spiegati nel capitolo 9.
  • published() è lo scope del capitolo 6. Sostituisce where('is_published', true), ripetuto in un modo o nell’altro nei cinque controller.
  • latest('published_at') ordina per data decrescente. Senza argomento il metodo ordina su created_at, che non è la stessa cosa: un articolo scritto lunedì e pubblicato venerdì deve comparire alla sua data di pubblicazione.
  • withCount('posts') aggiunge una colonna posts_count a ogni categoria, calcolata con una sottoquery. È quello che la barra laterale mostra tra parentesi, senza caricare gli articoli veri e propri.
  • with(['category', 'user']) è il punto importante. Lo vediamo qui sotto.

Il problema delle query N+1

L’elenco degli articoli mostra, per ciascuno, il nome della categoria e quello dell’autore. Senza precauzioni Eloquent carica prima i cinque articoli, poi va a cercare la categoria di ognuno, poi l’autore di ognuno: una query per l’elenco, più dieci. Con cinquanta articoli per pagina, centouno query.

with(['category', 'user']) chiede a Eloquent di caricare le relazioni in anticipo, con una query per relazione. I log dell’applicazione lo mostrano:

sql
select * from "posts" where "is_published" = 1 order by "published_at" desc limit 5 offset 0
select * from "categories" where "categories"."id" in (1, 2)
select * from "users" where "users"."id" in (1)

Tre query invece di undici, e il numero non si muove quando la pagina cresce. È l’ottimizzazione più redditizia di un’applicazione Laravel, e la più spesso dimenticata.

Il controller di categoria

app/Http/Controllers/CategoryController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class CategoryController extends Controller
{
    public function __invoke(Category $category): View
    {
        return view('category', [
            'category' => $category,
            'posts' => $category->posts()
                ->published()
                ->with(['category', 'user'])
                ->latest('published_at')
                ->paginate(5),
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

L’argomento Category $category è già l’oggetto caricato: Laravel l’ha risolto dallo slug dell’URL. Non c’è nessuna query da scrivere, e il caso «categoria inesistente» è gestito prima di entrare nel metodo.

$category->posts() con le parentesi restituisce una query che si può affinare. Senza parentesi, $category->posts restituirebbe direttamente la collection completa, senza filtri né paginazione. La distinzione bisogna e fonte di confusione frequente.

Il controller del tag

Esattamente lo stesso schema, con Tag:

app/Http/Controllers/TagController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class TagController extends Controller
{
    public function __invoke(Tag $tag): View
    {
        return view('tag', [
            'tag' => $tag,
            'posts' => $tag->posts()
                ->published()
                ->with(['category', 'user'])
                ->latest('published_at')
                ->paginate(5),
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

Il controller dell’articolo

app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class PostController extends Controller
{
    public function show(Post $post): View
    {
        abort_unless($post->is_published, 404);

        $post->load(['category', 'user', 'tags']);

        return view('post', [
            'post' => $post,
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

abort_unless($post->is_published, 404) merita una sosta. Il model binding trova l’articolo dal suo slug, che sia pubblicato o no: senza questa riga una bozza sarebbe visibile a chiunque indovini il suo indirizzo. La riga restituisce un 404 e non un 403, così non rivela nemmeno che l’articolo esiste.

load() è l’equivalente di with() su un oggetto già caricato. Senza, la stampa dei tag farebbe partire una query in più.

Le viste

Le tre pagine di elenco riutilizzano la vista parziale scritta nel capitolo 5.

resources/views/category.blade.php
@extends('layouts.app')

@section('title', $category->name)

@section('content')
    <h1 class="mb-2 text-2xl font-bold">Catégorie : {{ $category->name }}</h1>
    @if ($category->description)
        <p class="mb-6 text-gray-600">{{ $category->description }}</p>
    @endif
    @include('partials.posts-list')
@endsection
resources/views/tag.blade.php
@extends('layouts.app')

@section('title', $tag->name)

@section('content')
    <h1 class="mb-6 text-2xl font-bold">Étiquette : {{ $tag->name }}</h1>
    @include('partials.posts-list')
@endsection

La scheda di un articolo

resources/views/post.blade.php
@extends('layouts.app')

@section('title', $post->title)
@section('description', $post->excerpt ?: Str::limit(strip_tags($post->content), 150))

@section('content')
    <article class="rounded border bg-white p-6">
        <h1 class="text-3xl font-bold">{{ $post->title }}</h1>
        <p class="mt-2 text-sm text-gray-500">
            {{ $post->user?->name }} —
            <a href="http://%20route('category',%20$post-category)%20" class="hover:underline">{{ $post->category?->name }}</a>
            @if ($post->published_at) — {{ $post->published_at->translatedFormat('j F Y') }} @endif
        </p>

        @if ($post->featured_image)
            <img src="{{ Storage::url($post->featured_image) }}" alt="" class="mt-4 rounded">
        @endif

        <div class="prose mt-6 max-w-none">{!! $post->content !!}</div>

        @if ($post->tags->isNotEmpty())
            <ul class="mt-6 flex flex-wrap gap-2 text-sm">
                @foreach ($post->tags as $tag)
                    <li><a href="http://%20route('tag',%20$tag)%20" class="rounded bg-gray-200 px-2 py-0.5">{{ $tag->name }}</a></li>
                @endforeach
            </ul>
        @endif
    </article>
@endsection

{!! $post->content !!} stampa l’HTML prodotto dall’editor del back-office senza farne l’escape. Qui è legittimo perché il contenuto arriva da un amministratore autenticato. Non scrivere mai questa forma su un dato inserito da un visitatore.

Storage::url() costruisce l’indirizzo pubblico di un file caricato. Presuppone che php artisan storage:link sia stato eseguito nel capitolo 1, altrimenti l’immagine restituirà un 404.

Verificare

Le quattro pagine devono rispondere. Un test lo garantisce a ogni modifica futura. Il file completo, con il metodo makePost() che crea un articolo di prova, è dato nel capitolo 9, ecco il metodo che protegge le bozze:

tests/Feature/BlogTest.php
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();
}
bash
php artisan test
ExampleTest fallisce da questo capitolo

Il test fornito con Laravel, tests/Feature/ExampleTest.php, carica la home senza database: da quando la home interroga la tabella posts, fallisce con no such table: posts. Aggiungetegli il trait RefreshDatabase, come nei test di questo tutorial, oppure eliminatelo.

Il capitolo successivo aggiunge il motore di ricerca.

Errori frequenti

Una bozza resta visibile dal suo indirizzo Il model binding trova l'articolo, pubblicato o no. Aggiungi abort_unless($post->is_published, 404), altrimenti le tue bozze sono leggibili da chi indovina l'URL.
Dimenticare le parentesi su una relazione $category->posts restituisce la collection completa, impossibile da filtrare o paginare. $category->posts() restituisce una query che si può affinare.
La pagina rallenta man mano che cresce È il problema delle query N+1. with() carica le relazioni in anticipo e blocca il numero di query.
latest() ordina sulla colonna sbagliata Senza argomento, latest() ordina su created_at. Per un blog è published_at che devi passare.
Newsletter

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

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