Capítulo 7 de 9

Tutorial Laravel 13 #7: criar rotas, controladores e vistas

verificado a 7 Setembro 2026 · 8 min

Resposta rápida

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.

No fim deste capítulo, as páginas inicial, de categoria, de etiqueta e de artigo mostrarão os teus dados, sem uma consulta a mais.

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.

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

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

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');

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.

A ordem das rotas importa

/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

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

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 o where('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 por created_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 coluna posts_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:

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)

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

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

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:

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

O controlador de artigo

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) 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.

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

A ficha de um artigo

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

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 falha a partir deste capítulo

O 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.

Erros frequentes

Um rascunho continua visível pelo endereço O route model binding encontra o artigo esteja ele publicado ou não. Acrescenta abort_unless($post->is_published, 404), senão os teus rascunhos ficam legíveis para quem adivinhar o URL.
Esquecer os parênteses numa relação $category->posts devolve a coleção completa, impossível de filtrar ou de paginar. $category->posts() devolve uma consulta que se pode afinar.
A página fica mais lenta à medida que cresce É o problema das consultas N+1. with() carrega as relações de antemão e fixa o número de consultas.
latest() ordena pela coluna errada Sem argumento, latest() ordena por created_at. Num blogue, é published_at que tens de passar.
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.