Blade — Templates no Laravel

Blade — Templates no Laravel

Sintaxe, herança de layouts, componentes com slots e formulários com erros de validação, na ordem em que uma tela real pede cada um. O artigo mostra também por que @empty numa Collection nunca dispara e em quais três contextos o escape de {{ }} deixa de proteger.
PHP

• • 32 min de leitura

Blade é o motor de templates do Laravel. Ele compila templates para PHP puro e os armazena em cache, então não há penalidade de performance. A sintaxe é limpa, expressiva, e evita que você misture lógica de negócio com apresentação — um problema clássico do PHP puro com HTML.

Blade não é apenas uma forma de escrever <?php echo ?> mais curto. Oferece herança de layouts, componentes reutilizáveis, slots, stacks, e diretivas que tornam templates complexos gerenciáveis.

Sintaxe básica

{{-- Comentário Blade — não aparece no HTML final --}}

{{-- Exibir variável com escape automático (equivalente a htmlspecialchars) --}}
{{ $produto->nome }}

{{-- Exibir HTML sem escape — use com cuidado --}}
{!! $produto->descricao_html !!}

{{-- Expressões PHP --}}
{{ strtoupper($produto->nome) }}
{{ $produto->preco_formatado }}
{{ now()->format('d/m/Y') }}

Estruturas de controle

{{-- Condicionais. Repare na ORDEM: `estoque` é unsignedInteger, ou seja,
     nunca é negativo. Com `> 0` na primeira condição e `=== 0` na
     segunda, as duas juntas cobrem todos os valores possíveis e o `@else`
     vira código morto — o badge cinza nunca apareceria. Quem decide o
     terceiro caso é outra coluna. --}}
@if (! $produto->ativo)
    <span class="badge cinza">Indisponível</span>
@elseif ($produto->estoque > 0)
    <span class="badge verde">Em estoque</span>
@else
    <span class="badge vermelho">Esgotado</span>
@endif

{{-- unless = if negado --}}
@unless ($usuario->isAdmin())
    <p>Acesso restrito a administradores.</p>
@endunless

{{-- isset e empty --}}
@isset($produto->descricao)
    <p>{{ $produto->descricao }}</p>
@endisset

{{-- CUIDADO: `@empty` compila para o `empty()` do PHP, e `empty()` de
     objeto é SEMPRE false — inclusive de Collection e de Paginator vazios.
     Escrever `@empty($produtos)` com o resultado de um `get()` produz um
     bloco que nunca renderiza, e sem erro nenhum. A diretiva serve para
     escalar e array; para coleção, use `isEmpty()` ou o `@forelse`. --}}
@empty($termoDeBusca)
    <p>Digite algo para buscar.</p>
@endempty

@if ($produtos->isEmpty())
    <p>Nenhum produto encontrado.</p>
@endif

{{-- Loops --}}
@foreach ($produtos as $produto)
    <div class="produto">
        <h3>{{ $produto->nome }}</h3>
        <p>R$ {{ number_format($produto->preco, 2, ',', '.') }}</p>
    </div>
@endforeach

{{-- $loop — variável especial dentro de foreach --}}
@foreach ($produtos as $produto)
    <div class="{{ $loop->even ? 'par' : 'impar' }}">
        {{ $loop->iteration }}/{{ $loop->count }}: {{ $produto->nome }}
        @if ($loop->first) <span>← primeiro</span> @endif
        @if ($loop->last) <span>← último</span> @endif
    </div>
@endforeach

{{-- forelse: bloco para quando a coleção for vazia --}}
@forelse ($produtos as $produto)
    <p>{{ $produto->nome }}</p>
@empty
    <p>Nenhum produto cadastrado.</p>
@endforelse

{{-- for e while --}}
@for ($i = 1; $i <= 5; $i++)
    <p>Item {{ $i }}</p>
@endfor

Herança de layouts

O padrão mais comum no Blade é definir um layout principal que as views estendem:

{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <title>@yield('title', 'Minha Loja')</title>
    <link rel="stylesheet" href="{{ asset('css/app.css') }}">
    @stack('styles')
</head>
<body>
    <header>
        <nav>
            <a href="{{ route('home') }}">Início</a>
            <a href="{{ route('produtos.index') }}">Produtos</a>
            @auth
                <a href="{{ route('perfil') }}">{{ auth()->user()->nome }}</a>

                {{-- Sair é POST, não link. Um GET para /logout derruba a
                     sessão de quem apenas carregou uma página com
                     `<img src="/logout">`, e a rota do Breeze e do Fortify
                     nem aceita GET — responde 405. --}}
                <form method="POST" action="{{ route('logout') }}">
                    @csrf
                    <button type="submit">Sair</button>
                </form>
            @endauth
            @guest
                <a href="{{ route('login') }}">Entrar</a>
            @endguest
        </nav>
    </header>

    <main>
        @if (session('sucesso'))
            <div class="alerta sucesso">{{ session('sucesso') }}</div>
        @endif

        @if (session('erro'))
            <div class="alerta erro">{{ session('erro') }}</div>
        @endif

        @yield('content')
    </main>

    <footer>
        <p>© {{ date('Y') }} Minha Loja</p>
    </footer>

    <script src="{{ asset('js/app.js') }}"></script>
    @stack('scripts')
</body>
</html>
{{-- resources/views/produtos/index.blade.php --}}
@extends('layouts.app')

@section('title', 'Produtos | Minha Loja')

@push('styles')
    <link rel="stylesheet" href="{{ asset('css/produtos.css') }}">
@endpush

@section('content')
    <h1>Produtos</h1>

    <div class="grade-produtos">
        @forelse ($produtos as $produto)
            <div class="card-produto">
                <h3>
                    <a href="{{ route('produtos.show', $produto) }}">
                        {{ $produto->nome }}
                    </a>
                </h3>
                <p>{{ $produto->preco_formatado }}</p>
                <p>{{ $produto->categoria->nome }}</p>
            </div>
        @empty
            <p>Nenhum produto disponível.</p>
        @endforelse
    </div>

    {{-- Paginação --}}
    {{ $produtos->links() }}

@endsection

@push('scripts')
    <script src="{{ asset('js/produtos.js') }}"></script>
@endpush

Componentes

Componentes são o recurso mais poderoso do Blade moderno — permitem criar elementos reutilizáveis com lógica e template encapsulados:

php artisan make:component Alerta
php artisan make:component Produto/Card
<?php
// app/View/Components/Alerta.php

namespace App\View\Components;

use Illuminate\View\Component;

class Alerta extends Component
{
    public function __construct(
        public readonly string $tipo = 'info',  // info, sucesso, erro, aviso
        public readonly bool $descartavel = false,
    ) {}

    public function classes(): string
    {
        return match($this->tipo) {
            'sucesso' => 'alerta alerta-sucesso',
            'erro'    => 'alerta alerta-erro',
            'aviso'   => 'alerta alerta-aviso',
            default   => 'alerta alerta-info',
        };
    }

    public function render()
    {
        return view('components.alerta');
    }
}
{{-- resources/views/components/alerta.blade.php --}}
<div {{ $attributes->class([$classes()]) }}>
    {{ $slot }}
    @if ($descartavel)
        <button onclick="this.parentElement.remove()">×</button>
    @endif
</div>
{{-- Usando o componente --}}
<x-alerta tipo="sucesso">
    Produto criado com sucesso!
</x-alerta>

<x-alerta tipo="erro" :descartavel="true">
    Ocorreu um erro ao processar o pagamento.
</x-alerta>

{{-- Componente com múltiplos slots. O nome da tag segue a PASTA:
     `make:component Produto/Card` gera `<x-produto.card>`, com ponto. --}}
<x-produto.card>
    <x-slot name="header">
        <h3>{{ $produto->nome }}</h3>
    </x-slot>

    <p>{{ $produto->descricao }}</p>

    <x-slot name="footer">
        <a href="{{ route('produtos.show', $produto) }}">Ver mais</a>
    </x-slot>
</x-produto.card>

Formulários e erros de validação

{{-- Formulário de criação de produto --}}
<form method="POST" action="{{ route('produtos.store') }}" enctype="multipart/form-data">
    @csrf  {{-- Token CSRF automático --}}

    <div>
        <label for="nome">Nome</label>
        <input type="text"
               id="nome"
               name="nome"
               value="{{ old('nome') }}"
               class="{{ $errors->has('nome') ? 'erro' : '' }}">
        @error('nome')
            <span class="mensagem-erro">{{ $message }}</span>
        @enderror
    </div>

    <div>
        <label for="preco">Preço</label>
        <input type="number"
               id="preco"
               name="preco"
               step="0.01"
               value="{{ old('preco') }}">
        @error('preco')
            <span class="mensagem-erro">{{ $message }}</span>
        @enderror
    </div>

    <div>
        <label for="categoria_id">Categoria</label>
        <select id="categoria_id" name="categoria_id">
            <option value="">Selecione...</option>
            @foreach ($categorias as $categoria)
                <option value="{{ $categoria->id }}"
                    {{ old('categoria_id') == $categoria->id ? 'selected' : '' }}>
                    {{ $categoria->nome }}
                </option>
            @endforeach
        </select>
        @error('categoria_id')
            <span class="mensagem-erro">{{ $message }}</span>
        @enderror
    </div>

    {{-- Para edição: método PUT --}}
    {{-- @method('PUT') --}}

    <button type="submit">Salvar</button>
</form>

O Blade tem uma virtude que também é sua armadilha: quase toda diretiva é uma tradução direta para PHP, e as poucas que não são não avisam disso. O @empty vira empty() e mente sobre coleções; o {{ }} vira e() e protege um contexto só; o @foreach vira foreach e por isso não tem nada de especial. Quando algo se comporta de um jeito inesperado, a resposta está a um passo de distância — os arquivos compilados ficam em storage/framework/views/, são PHP legível, e lê-los é mais rápido do que adivinhar o que a diretiva fez.

A outra decisão que se paga cedo é a escolha entre @extends e componente. Herança de layout resolve a moldura da página: uma vez, no topo, com @yield onde o conteúdo entra. Componente resolve a peça que se repete e carrega comportamento junto — o alerta, o card, o campo de formulário com rótulo e mensagem de erro. Quando essa peça começa a receber cinco, seis parâmetros para decidir o que mostrar, o sinal é o mesmo de uma função longa demais: não é um componente, são dois. Vale separar antes que a view vire uma sequência de condicionais dentro de outra.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie um layout principal com header, footer e área de flash messages. Crie views para listar, exibir, criar e editar produtos, todas estendendo o layout.

Ver resposta

✓ Resposta: Duas linhas resolvem os bugs mais chatos de formulário. O <input type="hidden" name="ativo" value="0"> antes do checkbox: campo desmarcado não é enviado, e sem o hidden ele some do request — o produto nunca desativa. E withQueryString() na paginação, sem o qual ir para a página 2 perde os filtros. Sobre {!! !!}: ele não escapa nada, então só se justifica com HTML que você mesmo gerou. Com texto do usuário, é XSS armazenado — e a diferença entre as duas sintaxes são dois caracteres.

{{-- ---------------------------------------- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    {{-- @yield com valor padrão: a view que não definir o título ainda
         renderiza uma página com nome. --}}
    <title>@yield('titulo', 'Loja') — Minha Loja</title>

    {{-- O token para requisições fetch/axios. O @csrf dos formulários é
         outra coisa (campo oculto); este é para JavaScript. --}}
    <meta name="csrf-token" content="{{ csrf_token() }}">

    @vite(['resources/css/app.css', 'resources/js/app.js'])

    {{-- @stack recebe o que as views empilharem com @push --}}
    @stack('estilos')
</head>
<body>
    <header class="cabecalho">
        <a href="{{ route('home') }}" class="logo">Minha Loja</a>

        <nav>
            {{-- request()->routeIs() aceita curinga: 'produtos.*' marca o
                 menu como ativo em index, create, edit e show. --}}
            <a href="{{ route('produtos.index') }}"
               @class(['ativo' => request()->routeIs('produtos.*')])>Produtos</a>

            <a href="{{ route('categorias.index') }}"
               @class(['ativo' => request()->routeIs('categorias.*')])>Categorias</a>

            @auth
                <span>Olá, {{ auth()->user()->nome }}</span>

                <form method="post" action="{{ route('logout') }}">
                    @csrf
                    <button type="submit">Sair</button>
                </form>
            @else
                <a href="{{ route('login') }}">Entrar</a>
            @endauth
        </nav>
    </header>

    {{-- @include em vez de repetir o bloco de flash em cada view --}}
    @include('parciais.flash')

    <main class="conteudo">
        @yield('conteudo')
    </main>

    <footer>
        <p>&copy; {{ now()->year }} Minha Loja</p>
    </footer>

    @stack('scripts')
</body>
</html>

{{-- ---------------------------------------- resources/views/parciais/flash.blade.php --}}
@php
    // Um mapa em vez de quatro @if iguais: acrescentar um tipo de aviso
    // passa a ser uma linha aqui, não mais um bloco na view.
    $tipos = ['sucesso' => 'alerta--ok', 'erro' => 'alerta--erro', 'aviso' => 'alerta--aviso'];
@endphp

@foreach ($tipos as $chave => $classe)
    @session($chave)
        <div class="alerta {{ $classe }}" role="alert">
            {{ $value }}
        </div>
    @endsession
@endforeach

{{-- O bloco de erros de validação: o Laravel devolve o $errors em toda
     view, sempre — não é preciso passá-lo do controller. --}}
@if ($errors->any())
    <div class="alerta alerta--erro" role="alert">
        <strong>Confira os campos abaixo:</strong>
        <ul>
            @foreach ($errors->all() as $erro)
                <li>{{ $erro }}</li>
            @endforeach
        </ul>
    </div>
@endif

{{-- ---------------------------------------- resources/views/produtos/index.blade.php --}}
@extends('layouts.app')

@section('titulo', 'Produtos')

@section('conteudo')
    <div class="barra">
        <h1>Produtos</h1>

        {{-- @can lê a Policy: o botão nem existe para quem não pode criar.
             Continua sendo necessário autorizar no controller — esconder
             botão é usabilidade, não segurança. --}}
        @can('create', App\Models\Produto::class)
            <a href="{{ route('produtos.create') }}" class="botao">Novo produto</a>
        @endcan
    </div>

    <form method="get" class="filtros">
        <input type="search" name="q" value="{{ request('q') }}"
               placeholder="Buscar por nome...">

        <select name="categoria">
            <option value="">Todas as categorias</option>
            @foreach ($categorias as $categoria)
                {{-- @selected é mais legível que o ternário no atributo --}}
                <option value="{{ $categoria->id }}"
                    @selected(request('categoria') == $categoria->id)>
                    {{ $categoria->nome }}
                </option>
            @endforeach
        </select>

        <button type="submit">Filtrar</button>
    </form>

    <div class="grade">
        @forelse ($produtos as $produto)
            {{-- O componente da questão 2 --}}
            <x-produto-card :produto="$produto" />
        @empty
            <p class="vazio">
                Nenhum produto encontrado
                @if (request()->hasAny(['q', 'categoria']))
                    com esses filtros. <a href="{{ route('produtos.index') }}">Limpar</a>
                @endif
            </p>
        @endforelse
    </div>

    {{-- withQueryString preserva os filtros ao trocar de página; sem ele,
         ir para a página 2 perde a busca. --}}
    {{ $produtos->withQueryString()->links() }}
@endsection

{{-- ---------------------------------------- resources/views/produtos/show.blade.php --}}
@extends('layouts.app')

@section('titulo', $produto->nome)

@section('conteudo')
    <article class="produto">
        <h1>{{ $produto->nome }}</h1>

        <p class="migalha">
            <a href="{{ route('categorias.show', $produto->categoria) }}">
                {{ $produto->categoria->nome }}
            </a>
        </p>

        <p class="preco">R$ {{ number_format($produto->preco, 2, ',', '.') }}</p>

        {{-- {!! !!} NÃO escapa. Só se justifica com HTML que VOCÊ gerou —
             aqui, a descrição passou por um sanitizador antes de salvar.
             Com texto cru do usuário, isto é XSS armazenado. --}}
        <div class="descricao">{!! $produto->descricao_html !!}</div>

        <p>
            @if ($produto->estoque > 0)
                {{ $produto->estoque }} em estoque
            @else
                <strong>Esgotado</strong>
            @endif
        </p>

        @can('update', $produto)
            <a href="{{ route('produtos.edit', $produto) }}">Editar</a>
        @endcan
    </article>
@endsection

{{-- ---------------------------------------- resources/views/produtos/form.blade.php --}}
@extends('layouts.app')

{{-- Uma view para criar E editar: o que muda é a rota e o método. Duas
     views quase idênticas divergem na primeira alteração que alguém
     esquecer de replicar. --}}
@php
    $editando = $produto->exists;
@endphp

@section('titulo', $editando ? 'Editar produto' : 'Novo produto')

@section('conteudo')
    <h1>{{ $editando ? "Editar: {$produto->nome}" : 'Novo produto' }}</h1>

    <form method="post"
          action="{{ $editando
              ? route('produtos.update', $produto)
              : route('produtos.store') }}">
        @csrf

        @if ($editando)
            @method('PUT')
        @endif

        <div class="campo">
            <label for="nome">Nome</label>
            {{-- old() repopula o campo depois de um erro de validação; o
                 segundo argumento é o valor atual, usado na edição. --}}
            <input type="text" id="nome" name="nome"
                   value="{{ old('nome', $produto->nome) }}"
                   @class(['invalido' => $errors->has('nome')])
                   required>

            {{-- @error abre um escopo com a variável $message --}}
            @error('nome')
                <p class="erro">{{ $message }}</p>
            @enderror
        </div>

        <div class="campo">
            <label for="categoria_id">Categoria</label>
            <select id="categoria_id" name="categoria_id" required>
                <option value="">Selecione...</option>
                @foreach ($categorias as $categoria)
                    <option value="{{ $categoria->id }}"
                        @selected(old('categoria_id', $produto->categoria_id) == $categoria->id)>
                        {{ $categoria->nome }}
                    </option>
                @endforeach
            </select>
            @error('categoria_id') <p class="erro">{{ $message }}</p> @enderror
        </div>

        <div class="campo">
            <label for="preco">Preço</label>
            <input type="number" id="preco" name="preco" step="0.01" min="0"
                   value="{{ old('preco', $produto->preco) }}" required>
            @error('preco') <p class="erro">{{ $message }}</p> @enderror
        </div>

        <div class="campo">
            <label>
                <input type="hidden" name="ativo" value="0">
                {{-- O hidden antes do checkbox é o truque que resolve o
                     problema clássico: checkbox desmarcado NÃO é enviado, e
                     sem o hidden o campo simplesmente some do request. --}}
                <input type="checkbox" name="ativo" value="1"
                       @checked(old('ativo', $produto->ativo ?? true))>
                Produto ativo
            </label>
        </div>

        <button type="submit">{{ $editando ? 'Salvar' : 'Criar' }}</button>
        <a href="{{ route('produtos.index') }}">Cancelar</a>
    </form>
@endsection

Exercício 2

Construa um componente <x-produto-card> que recebe um $produto e exibe nome, preço, categoria e estoque. Use o componente na listagem.

Ver resposta

✓ Resposta: A divisão que faz o componente valer a pena: o que é lógica de apresentação (formatar preço, decidir o rótulo do estoque) vira método da classe; o Blade fica só com marcação. E $attributes->merge() é o detalhe que separa componente reutilizável de componente engessado — sem ele, passar uma class extra na tag substitui a classe base em vez de somar. Quando não há lógica nenhuma, o componente anônimo com @props dispensa a classe inteira.

{{-- ============ no terminal ============
     php artisan make:component ProdutoCard

     Cria dois arquivos:
       app/View/Components/ProdutoCard.php
       resources/views/components/produto-card.blade.php

     E o componente passa a ser usado como <x-produto-card />.
     O nome em kebab-case da tag mapeia para o PascalCase da classe. --}}

{{-- ---------------------------------------- app/View/Components/ProdutoCard.php --}}
<?php

declare(strict_types=1);

namespace App\View\Components;

use App\Models\Produto;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;

class ProdutoCard extends Component
{
    public function __construct(
        public Produto $produto,
        public bool $compacto = false,
        public bool $mostrarCategoria = true,
    ) {}

    /**
     * Toda propriedade pública fica disponível na view do componente —
     * inclusive os métodos, chamados como $this->metodo() ou expostos
     * como abaixo. É aqui que mora a lógica de APRESENTAÇÃO, tirando-a
     * de dentro do Blade.
     */
    public function precoFormatado(): string
    {
        return 'R$ ' . number_format((float) $this->produto->preco, 2, ',', '.');
    }

    public function situacaoDoEstoque(): string
    {
        return match (true) {
            $this->produto->estoque === 0  => 'esgotado',
            $this->produto->estoque <= 5   => 'ultimas-unidades',
            default                        => 'disponivel',
        };
    }

    public function rotuloDoEstoque(): string
    {
        return match ($this->situacaoDoEstoque()) {
            'esgotado'         => 'Esgotado',
            'ultimas-unidades' => "Últimas {$this->produto->estoque} unidades",
            default            => 'Em estoque',
        };
    }

    public function render(): View|Closure|string
    {
        return view('components.produto-card');
    }
}

{{-- ---------------------------------------- resources/views/components/produto-card.blade.php --}}
{{-- $attributes carrega tudo que foi passado na tag e NÃO é propriedade
     do construtor: class, id, data-*, wire:click… O merge preserva a
     classe base e acrescenta a que veio de fora. --}}
<article {{ $attributes->merge(['class' => 'card card--produto']) }}
         @class([
             'card--compacto' => $compacto,
             'card--esgotado' => $produto->estoque === 0,
         ])>

    <a href="{{ route('produtos.show', $produto) }}" class="card__link">
        <h3 class="card__nome">{{ $produto->nome }}</h3>
    </a>

    @if ($mostrarCategoria)
        <p class="card__categoria">
            {{-- optional chaining do Blade: se a categoria não estiver
                 carregada nem existir, imprime o fallback em vez de
                 estourar "Attempt to read property on null". --}}
            {{ $produto->categoria?->nome ?? 'Sem categoria' }}
        </p>
    @endif

    @unless ($compacto)
        <p class="card__descricao">
            {{ Str::limit($produto->descricao, 120) }}
        </p>
    @endunless

    <p class="card__preco">{{ $precoFormatado() }}</p>

    <p class="card__estoque card__estoque--{{ $situacaoDoEstoque() }}">
        {{ $rotuloDoEstoque() }}
    </p>

    {{-- Slot nomeado opcional: quem usa o componente pode injetar botões
         próprios sem que o componente precise conhecê-los. --}}
    @isset($acoes)
        <div class="card__acoes">
            {{ $acoes }}
        </div>
    @endisset
</article>

{{-- ---------------------------------------- usando na listagem --}}
{{-- forma simples --}}
<x-produto-card :produto="$produto" />

{{-- com atributos extras: os que não são do construtor caem em $attributes --}}
<x-produto-card :produto="$produto"
                :compacto="true"
                class="card--destaque"
                data-id="{{ $produto->id }}" />

{{-- com slot nomeado --}}
<x-produto-card :produto="$produto">
    <x-slot:acoes>
        <form method="post" action="{{ route('carrinho.adicionar', $produto) }}">
            @csrf
            <button type="submit" @disabled($produto->estoque === 0)>
                Adicionar ao carrinho
            </button>
        </form>
    </x-slot:acoes>
</x-produto-card>

{{-- dentro do @foreach da listagem --}}
<div class="grade">
    @foreach ($produtos as $produto)
        <x-produto-card :produto="$produto" :mostrar-categoria="false" />
    @endforeach
</div>

{{-- ---------------------------------------- componente ANÔNIMO (sem classe) --}}
{{-- resources/views/components/alerta.blade.php
     Não tem classe PHP: para o que é só marcação, a classe seria cerimônia.
     O @props declara o que ele aceita e os valores padrão. --}}
@props(['tipo' => 'aviso', 'titulo' => null])

@php
    $classes = ['ok' => 'alerta--ok', 'erro' => 'alerta--erro', 'aviso' => 'alerta--aviso'];
@endphp

<div {{ $attributes->merge(['class' => 'alerta ' . ($classes[$tipo] ?? '')]) }} role="alert">
    @if ($titulo)
        <strong>{{ $titulo }}</strong>
    @endif

    {{-- $slot é o conteúdo entre a tag de abertura e a de fechamento --}}
    {{ $slot }}
</div>

{{-- uso: <x-alerta tipo="erro" titulo="Ops">Não foi possível salvar.</x-alerta> --}}

Exercício 3

Implemente um componente <x-paginacao> que envolve $colecao->links() com estilos customizados.

Ver resposta

✓ Resposta: Três tipos de paginação, três custos. paginate() roda um COUNT(*) extra para saber o total — é o que permite mostrar "página 7 de 90". simplePaginate() dispensa esse COUNT (mais rápido, mas sem total). E cursorPaginate() troca OFFSET por WHERE id > ?, o único que não fica mais lento nas páginas finais — em tabela grande, OFFSET 500000 faz o banco varrer e descartar meio milhão de linhas antes de devolver 20.

{{-- ============ o caminho oficial ============
     php artisan vendor:publish --tag=laravel-pagination

     Copia as views padrão para resources/views/vendor/pagination/.
     Serve para ajustes pequenos. Para controle real da marcação, o
     componente abaixo envolve $colecao->links() e decide tudo. --}}

{{-- ---------------------------------------- resources/views/components/paginacao.blade.php --}}
@props([
    'colecao',
    'aoRedor' => 2,      {{-- páginas visíveis de cada lado da atual --}}
    'mostrarResumo' => true,
])

{{-- hasPages() é falso quando tudo cabe numa página — sem esta guarda, a
     lista de 3 itens renderiza uma paginação inútil. --}}
@if ($colecao->hasPages())
    <nav {{ $attributes->merge(['class' => 'paginacao']) }}
         role="navigation"
         aria-label="Navegação entre páginas">

        @if ($mostrarResumo)
            <p class="paginacao__resumo">
                {{-- firstItem() e lastItem() são null na página vazia --}}
                Mostrando {{ $colecao->firstItem() ?? 0 }}–{{ $colecao->lastItem() ?? 0 }}
                de {{ $colecao->total() }} resultado(s)
            </p>
        @endif

        <ul class="paginacao__lista">
            {{-- ANTERIOR --}}
            @if ($colecao->onFirstPage())
                <li class="paginacao__item paginacao__item--inativo" aria-disabled="true">
                    <span>&laquo; Anterior</span>
                </li>
            @else
                <li class="paginacao__item">
                    {{-- rel=prev ajuda o navegador a pré-carregar e é sinal
                         de estrutura para buscadores. --}}
                    <a href="{{ $colecao->previousPageUrl() }}" rel="prev">&laquo; Anterior</a>
                </li>
            @endif

            {{-- NÚMEROS, com reticências --}}
            @php
                $atual  = $colecao->currentPage();
                $ultima = $colecao->lastPage();

                // A janela desliza, mas nunca sai dos limites: nas pontas
                // ela é reposicionada para manter sempre o mesmo tamanho.
                $inicio = max(1, min($atual - $aoRedor, $ultima - ($aoRedor * 2)));
                $fim    = min($ultima, max($atual + $aoRedor, ($aoRedor * 2) + 1));
            @endphp

            @if ($inicio > 1)
                <li class="paginacao__item">
                    <a href="{{ $colecao->url(1) }}">1</a>
                </li>

                @if ($inicio > 2)
                    <li class="paginacao__item paginacao__item--reticencias">
                        <span aria-hidden="true">…</span>
                    </li>
                @endif
            @endif

            @for ($pagina = $inicio; $pagina <= $fim; $pagina++)
                @if ($pagina == $atual)
                    <li class="paginacao__item paginacao__item--atual" aria-current="page">
                        <span>{{ $pagina }}</span>
                    </li>
                @else
                    <li class="paginacao__item">
                        <a href="{{ $colecao->url($pagina) }}">{{ $pagina }}</a>
                    </li>
                @endif
            @endfor

            @if ($fim < $ultima)
                @if ($fim < $ultima - 1)
                    <li class="paginacao__item paginacao__item--reticencias">
                        <span aria-hidden="true">…</span>
                    </li>
                @endif

                <li class="paginacao__item">
                    <a href="{{ $colecao->url($ultima) }}">{{ $ultima }}</a>
                </li>
            @endif

            {{-- PRÓXIMA --}}
            @if ($colecao->hasMorePages())
                <li class="paginacao__item">
                    <a href="{{ $colecao->nextPageUrl() }}" rel="next">Próxima &raquo;</a>
                </li>
            @else
                <li class="paginacao__item paginacao__item--inativo" aria-disabled="true">
                    <span>Próxima &raquo;</span>
                </li>
            @endif
        </ul>
    </nav>
@endif

{{-- ---------------------------------------- uso --}}
<x-paginacao :colecao="$produtos" />
<x-paginacao :colecao="$produtos" :ao-redor="3" :mostrar-resumo="false" />
<x-paginacao :colecao="$produtos" class="paginacao--compacta" />

{{-- ---------------------------------------- no controller --}}
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Produto;
use Illuminate\Http\Request;
use Illuminate\View\View;

class ProdutoController extends Controller
{
    public function index(Request $request): View
    {
        $produtos = Produto::query()
            ->with('categoria')
            ->when($request->string('q')->trim()->value(), function ($consulta, string $termo): void {
                $consulta->where('nome', 'like', "%{$termo}%");
            })
            ->orderBy('nome')
            // withQueryString mantém ?q= e ?categoria= nos links das
            // páginas. Sem ele, a página 2 esquece o filtro.
            ->paginate(perPage: 12)
            ->withQueryString();

        return view('produtos.index', compact('produtos'));
    }

    public function catalogoInfinito(Request $request): View
    {
        // simplePaginate faz UMA consulta a menos: não roda o COUNT(*) do
        // total. Em tabela grande, é a diferença entre 40 ms e 2 s — mas
        // some com total() e lastPage(), então serve para "carregar mais",
        // não para paginação numerada.
        $produtos = Produto::query()->orderBy('id')->simplePaginate(20);

        // Para tabelas realmente grandes, cursorPaginate: usa WHERE id > ?
        // em vez de OFFSET, e não fica mais lento nas páginas do fim.
        // $produtos = Produto::query()->orderBy('id')->cursorPaginate(20);

        return view('produtos.infinito', compact('produtos'));
    }
}

Exercício 4

Desafio: crie um componente <x-data-table> genérico que recebe $colunas (array de configuração) e $dados (coleção) e renderiza uma tabela completa com ordenação por coluna via query string.

Ver resposta

✓ Resposta: Duas coisas seguram este componente de pé. A lista branca no controller: o que vem em ?ordenar= é uma chave de um mapa escrito por você — interpolar request('ordenar') num orderBy é injeção de SQL pela porta da frente, porque prepared statement não liga identificador. E 'page' => null ao trocar a ordenação: sem isso, clicar no cabeçalho estando na página 7 mostra um pedaço aleatório do meio da nova ordem. data_get() é o detalhe elegante — ele navega 'categoria.nome' e devolve null em vez de estourar quando a relação não veio.

{{-- ---------------------------------------- app/View/Components/Tabela.php --}}
<?php

declare(strict_types=1);

namespace App\View\Components;

use Closure;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\View\Component;

/**
 * Tabela dirigida por configuração.
 *
 * $colunas é uma lista de descrições de coluna:
 *
 *   ['campo' => 'nome',  'titulo' => 'Nome',   'ordenavel' => true]
 *   ['campo' => 'preco', 'titulo' => 'Preço',  'ordenavel' => true,
 *    'formato' => 'dinheiro', 'alinhamento' => 'direita']
 *   ['campo' => 'categoria.nome', 'titulo' => 'Categoria']   ← ponto navega relação
 *   ['campo' => 'acoes', 'titulo' => '', 'view' => 'parciais.acoes-produto']
 */
class Tabela extends Component
{
    /** @var list<array<string, mixed>> */
    public array $colunas;

    public function __construct(
        array $colunas,
        public Collection|LengthAwarePaginator $dados,
        public string $vazio = 'Nenhum registro encontrado.',
        public ?string $parametroOrdem = 'ordenar',
        public ?string $parametroDirecao = 'direcao',
    ) {
        // Normaliza uma vez, no construtor: o Blade não deveria precisar de
        // ?? em cada célula.
        $this->colunas = array_map(static fn (array $coluna): array => [
            'campo'       => $coluna['campo'],
            'titulo'      => $coluna['titulo'] ?? Str::headline($coluna['campo']),
            'ordenavel'   => $coluna['ordenavel'] ?? false,
            'formato'     => $coluna['formato'] ?? 'texto',
            'alinhamento' => $coluna['alinhamento'] ?? 'esquerda',
            'view'        => $coluna['view'] ?? null,
        ], $colunas);
    }

    public function ordemAtual(): ?string
    {
        return request()->string($this->parametroOrdem)->value() ?: null;
    }

    public function direcaoAtual(): string
    {
        return request()->string($this->parametroDirecao)->value() === 'desc' ? 'desc' : 'asc';
    }

    /** URL do cabeçalho: clicar na coluna atual INVERTE a direção. */
    public function urlDaColuna(string $campo): string
    {
        $inverter = $this->ordemAtual() === $campo && $this->direcaoAtual() === 'asc';

        return request()->fullUrlWithQuery([
            $this->parametroOrdem   => $campo,
            $this->parametroDirecao => $inverter ? 'desc' : 'asc',
            // Voltar para a página 1: ordenar e continuar na página 7 mostra
            // um pedaço aleatório do meio da nova ordenação.
            'page'                  => null,
        ]);
    }

    public function indicador(string $campo): string
    {
        if ($this->ordemAtual() !== $campo) {
            return '↕';
        }

        return $this->direcaoAtual() === 'asc' ? '↑' : '↓';
    }

    /** Lê 'categoria.nome' navegando pelo relacionamento. */
    public function valor(object $linha, string $campo): mixed
    {
        return data_get($linha, $campo);
    }

    public function formatar(mixed $valor, string $formato): string
    {
        if ($valor === null) {
            return '—';
        }

        return match ($formato) {
            'dinheiro' => 'R$ ' . number_format((float) $valor, 2, ',', '.'),
            'data'     => $valor instanceof \DateTimeInterface
                ? $valor->format('d/m/Y')
                : (string) $valor,
            'datahora' => $valor instanceof \DateTimeInterface
                ? $valor->format('d/m/Y H:i')
                : (string) $valor,
            'booleano' => $valor ? 'Sim' : 'Não',
            'numero'   => number_format((float) $valor, 0, ',', '.'),
            default    => (string) $valor,
        };
    }

    public function render(): View|Closure|string
    {
        return view('components.tabela');
    }
}

{{-- ---------------------------------------- resources/views/components/tabela.blade.php --}}
<div {{ $attributes->merge(['class' => 'tabela-container']) }}>
    <table class="tabela">
        <thead>
            <tr>
                @foreach ($colunas as $coluna)
                    <th class="tabela__th tabela__th--{{ $coluna['alinhamento'] }}"
                        @if ($coluna['ordenavel'] && $ordemAtual() === $coluna['campo'])
                            aria-sort="{{ $direcaoAtual() === 'asc' ? 'ascending' : 'descending' }}"
                        @endif>

                        @if ($coluna['ordenavel'])
                            <a href="{{ $urlDaColuna($coluna['campo']) }}" class="tabela__ordenar">
                                {{ $coluna['titulo'] }}
                                <span aria-hidden="true">{{ $indicador($coluna['campo']) }}</span>
                            </a>
                        @else
                            {{ $coluna['titulo'] }}
                        @endif
                    </th>
                @endforeach
            </tr>
        </thead>

        <tbody>
            @forelse ($dados as $linha)
                <tr>
                    @foreach ($colunas as $coluna)
                        <td class="tabela__td tabela__td--{{ $coluna['alinhamento'] }}">
                            @if ($coluna['view'])
                                {{-- Coluna com marcação própria (botões,
                                     badges): a view recebe a linha inteira. --}}
                                @include($coluna['view'], ['linha' => $linha])
                            @else
                                {{ $formatar($valor($linha, $coluna['campo']), $coluna['formato']) }}
                            @endif
                        </td>
                    @endforeach
                </tr>
            @empty
                <tr>
                    <td colspan="{{ count($colunas) }}" class="tabela__vazio">
                        {{ $vazio }}
                    </td>
                </tr>
            @endforelse
        </tbody>
    </table>

    @if ($dados instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator)
        <x-paginacao :colecao="$dados" />
    @endif
</div>

{{-- ---------------------------------------- no controller --}}
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Produto;
use Illuminate\Http\Request;
use Illuminate\View\View;

class ProdutoController extends Controller
{
    /**
     * A lista de colunas ordenáveis é uma LISTA BRANCA, e é ela que impede
     * a injeção: `?ordenar=preco; DROP TABLE` não está no mapa, então cai
     * no padrão. Nunca interpole request('ordenar') num orderBy.
     */
    private const ORDENAVEIS = [
        'nome'           => 'produtos.nome',
        'preco'          => 'produtos.preco',
        'estoque'        => 'produtos.estoque',
        'created_at'     => 'produtos.created_at',
        // Ordenar por coluna de relação exige o JOIN — daí o nome
        // qualificado, e não `categoria.nome`.
        'categoria.nome' => 'categorias.nome',
    ];

    public function index(Request $request): View
    {
        $ordenar = $request->string('ordenar')->value();
        $coluna  = self::ORDENAVEIS[$ordenar] ?? 'produtos.nome';
        $direcao = $request->string('direcao')->value() === 'desc' ? 'desc' : 'asc';

        $produtos = Produto::query()
            ->select('produtos.*')
            ->join('categorias', 'categorias.id', '=', 'produtos.categoria_id')
            ->with('categoria')
            ->orderBy($coluna, $direcao)
            ->paginate(20)
            ->withQueryString();

        return view('produtos.index', [
            'produtos' => $produtos,
            'colunas'  => [
                ['campo' => 'nome',           'titulo' => 'Produto',   'ordenavel' => true],
                ['campo' => 'categoria.nome', 'titulo' => 'Categoria', 'ordenavel' => true],
                ['campo' => 'preco',          'titulo' => 'Preço',     'ordenavel' => true,
                 'formato' => 'dinheiro', 'alinhamento' => 'direita'],
                ['campo' => 'estoque',        'titulo' => 'Estoque',   'ordenavel' => true,
                 'formato' => 'numero',   'alinhamento' => 'direita'],
                ['campo' => 'created_at',     'titulo' => 'Cadastro',  'ordenavel' => true,
                 'formato' => 'data'],
                ['campo' => 'acoes',          'titulo' => '',
                 'view' => 'parciais.acoes-produto'],
            ],
        ]);
    }
}

{{-- ---------------------------------------- resources/views/produtos/index.blade.php --}}
@extends('layouts.app')

@section('conteudo')
    <h1>Produtos</h1>

    <x-tabela :colunas="$colunas"
              :dados="$produtos"
              vazio="Nenhum produto cadastrado ainda." />
@endsection

{{-- ---------------------------------------- resources/views/parciais/acoes-produto.blade.php --}}
<div class="acoes">
    <a href="{{ route('produtos.show', $linha) }}">Ver</a>

    @can('update', $linha)
        <a href="{{ route('produtos.edit', $linha) }}">Editar</a>
    @endcan
</div>

Exercício 5

O artigo apresenta {{ $var }} como "escape automático, equivalente a htmlspecialchars". Ponha a afirmação à prova em três contextos diferentes, com $valor vindo do usuário. Primeiro: <div class="{{ $valor }}"> e <div class={{ $valor }}> — as duas resistem a x onmouseover=alert(1)? Segundo: <script>var nome = "{{ $valor }}";</script> com o valor Jack O'Neill & "Bob" — o que chega em nome? Terceiro: <a href="{{ $valor }}"> com javascript:alert(1). Descubra em que camada cada uma falha.

Ver resposta

✓ Resposta: O {{ }} compila para e($x), que é htmlspecialchars($x, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', true). Ele escapa exatamente cinco caracteres — & < > " ' — e mais nada. Isso basta para um contexto: texto dentro de HTML, e atributo entre aspas. Os outros dois casos escapam do alcance da função, e cada um falha de um jeito diferente:

<?php
$valor = 'x onmouseover=alert(1)';
echo e($valor);   // x onmouseover=alert(1)   ← saiu intacto: não há & < > " ' nele

// <div class="{{ $valor }}">  →  seguro: o atributo tem aspas, e " está escapado
// <div class={{ $valor }}>    →  INJETADO: sem aspas, o espaço já encerra o
//                                 atributo e `onmouseover` vira outro atributo

Dentro de <script> a falha não é de segurança, é de corrupção silenciosa: o conteúdo de um <script> é texto bruto, e o navegador não decodifica entidade HTML ali. O valor chega em JavaScript com os &#039; visíveis:

<?php
$nome = 'Jack O\'Neill & "Bob" <3';

// <script>var nome = "{{ $nome }}";</script> produz:
var nome = "Jack O&#039;Neill &amp; &quot;Bob&quot; &lt;3";
//          ↑ é isso mesmo que o usuário vê na tela, entidades e tudo

// <script>var nome = @json($nome);</script> produz:
var nome = "Jack O\u0027Neill \u0026 \u0022Bob\u0022 \u003C3";
//          ↑ o @json compila para json_encode($x, 15, 512), e o 15 é
//            JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT — por
//            isso nem um `</script>` no meio do valor quebra a página

O terceiro caso é o mais ingrato, porque o escape funciona e o ataque passa assim mesmo: javascript:alert(1) não tem nenhum dos cinco caracteres, então href="{{ $valor }}" entrega a URL intacta e o clique executa o script. Aqui nenhuma função de escape resolve — a defesa é validar o esquema antes de imprimir, aceitando só http e https (e mailto, se for o caso).

A regra que sobra dos três: escape é uma função do contexto de destino, não do dado. {{ }} para texto e atributo entre aspas — e aspas sempre, em todo atributo; @json para qualquer coisa que atravesse para JavaScript; validação de esquema para URL; e {!! !!} só depois de passar por um sanitizador de HTML de verdade, como o HTMLPurifier. Um {{ }} no lugar errado não avisa que está no lugar errado.

Comentários

Mais em PHP

Namespaces, Autoloading, PSR-4 e Composer
Namespaces, Autoloading, PSR-4 e Composer

Sem namespace, dois arquivos com a mesma classe brigam pelo mesmo nome. O…

Performance e Otimização
Performance e Otimização

OPcache, cache de queries no Redis, o problema N+1, índices, filas e profiling…

Upload de Arquivos
Upload de Arquivos

Validar pelo conteúdo e não pelo que o navegador declara, gerar o nome no…