Tutorial Laravel 13 #7: creare rotte, controller e viste
verificato il 7 Settembre 2026 · 8 min
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.
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.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerCreateli 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
<?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.
/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
<?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. Sostituiscewhere('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 sucreated_at, che non è la stessa cosa: un articolo scritto lunedì e pubblicato venerdì deve comparire alla sua data di pubblicazione.withCount('posts')aggiunge una colonnaposts_counta 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:
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
<?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:
<?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
<?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.
@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@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')
@endsectionLa scheda di un articolo
@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:
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();
}php artisan testIl 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.