Tutorial Laravel 13 #7: criar rotas, controladores e vistas
verificado a 7 Setembro 2026 · 8 min
Cada página recebe um controlador que interroga os modelos e devolve uma vista. Acrescenta sempre with(['category', 'user']) às listagens: com cinco artigos, o carregamento antecipado faz a página passar de 11 consultas para 3.
Os dados já existem e o painel de administração permite introduzi-los. Este capítulo mostra-os: a página inicial, as páginas de categoria e de etiqueta, e a ficha de um artigo.
Criar os controladores
Três controladores só têm uma coisa a fazer, daí --invokable. IndexController já existe desde o capítulo 5: o seu conteúdo é substituído mais abaixo. PostController terá dois métodos: mostrar um artigo e tratar a pesquisa no capítulo seguinte.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerCrie-os antes de escrever as rotas: uma rota da forma Route::get('/…', CategoryController::class) exige que a classe exista, sem o que mais nenhum comando artisan arranca.
As rotas
<?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');Cada rota tem um nome: as vistas constroem os seus links com route('post', $post), nunca com um endereço escrito à mão.
Graças ao getRouteKeyName() declarado nos modelos no capítulo 6, os parâmetros {category}, {tag} e {post} são resolvidos pela coluna slug. O Laravel carrega o registo e devolve um 404 se nada corresponder, antes mesmo de entrar no controlador.
/recherche é declarada antes das rotas com parâmetro. Se tivesses escrito Route::get('/{slug}', …) mais acima, ela apanhava /recherche e procurava um artigo com esse slug. O Laravel detém-se na primeira rota que corresponde: os endereços fixos vêm antes dos endereços variáveis.
O controlador da página inicial
<?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(),
]);
}
}Há quatro métodos que merecem uma explicação.
paginate(5)divide a lista em páginas de cinco artigos. O seu funcionamento e a forma de mostrar os links estão detalhados no capítulo 9.published()é o scope do capítulo 6. Substitui owhere('is_published', true), repetido de uma forma ou de outra nos cinco controladores.latest('published_at')ordena por data decrescente. Sem argumento, o método ordena porcreated_at, o que não é a mesma coisa: um artigo escrito na segunda-feira e publicado na sexta deve aparecer na sua data de publicação.withCount('posts')acrescenta a cada categoria uma colunaposts_count, calculada por uma subconsulta. É o que a barra lateral mostra entre parênteses, sem carregar os artigos em si.with(['category', 'user'])é o ponto importante. Vê já a seguir.
O problema das consultas N+1
A lista de artigos mostra, para cada um, o nome da sua categoria e o do seu autor. Sem cuidados, o Eloquent carrega primeiro os cinco artigos, depois vai buscar a categoria de cada um e depois o autor de cada um: uma consulta para a lista, mais dez. Com cinquenta artigos por página, cento e uma consultas.
with(['category', 'user']) pede ao Eloquent que carregue as relações de antemão, numa consulta por relação. Os logs da aplicação mostram-no:
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)Três consultas em vez de onze, e o número não se mexe quando a página cresce. É a otimização mais rentável de uma aplicação Laravel, e a que mais vezes se esquece.
O controlador de 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(),
]);
}
}O argumento Category $category já é o objeto carregado: o Laravel resolveu-o a partir do slug do URL. Não há consulta nenhuma a escrever, e o caso «categoria inexistente» fica tratado antes de se entrar no método.
$category->posts() com os parênteses devolve uma consulta que se pode afinar. Sem eles, $category->posts devolveria diretamente a coleção completa, sem filtro nem paginação. A distinção é fundamental e uma fonte frequente de confusão.
O controlador de etiqueta
Exatamente o mesmo esquema, com 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(),
]);
}
}O controlador de artigo
<?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) merece que se pare um momento. O route model binding encontra o artigo pelo seu slug, esteja publicado ou não: sem esta linha, um rascunho ficava visível para quem adivinhasse o endereço. A linha devolve um 404 e não um 403, o que nem sequer revela que o artigo existe.
load() é o equivalente de with() num objeto já carregado. Sem ele, mostrar as etiquetas lançava mais uma consulta.
As vistas
As três páginas de listagem reutilizam a vista parcial escrita no capítulo 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')
@endsectionA ficha de um artigo
@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 !!} mostra o HTML produzido pelo editor do painel de administração sem o escapar. Aqui é legítimo porque o conteúdo vem de um administrador autenticado. Nunca escrevas esta forma sobre um dado introduzido por um visitante.
Storage::url() constrói o endereço público de um ficheiro carregado. Parte do princípio de que o php artisan storage:link foi executado no capítulo 1, caso contrário, a imagem devolve um 404.
Verificar
As quatro páginas têm de responder. Um teste garante-o a cada alteração futura. O ficheiro completo, com o método makePost() que cria um artigo de teste, é dado no capítulo 9, eis o método que protege os rascunhos:
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 testO teste fornecido com o Laravel, tests/Feature/ExampleTest.php, carrega a página inicial sem base de dados: desde que a página inicial consulta a tabela posts, falha com no such table: posts. Acrescente-lhe o trait RefreshDatabase, como nos testes deste tutorial, ou apague-o.
O capítulo seguinte acrescenta o motor de pesquisa.