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
- Blade Templates — Documentação: https://laravel.com/docs/blade
- Blade Components: https://laravel.com/docs/blade#components
- Laravel Collective Forms (helper adicional): https://laravelcollective.com/
- Laravel — views e como os dados chegam ao template: https://laravel.com/docs/views
- Laravel — Vite, o empacotador de CSS e JS que o Blade referencia com
@vite: https://laravel.com/docs/vite - Laravel — paginacao, que ja vem com a view pronta para o Blade: https://laravel.com/docs/pagination
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>© {{ 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>« 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">« 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 »</a>
</li>
@else
<li class="paginacao__item paginacao__item--inativo" aria-disabled="true">
<span>Próxima »</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 ' visíveis:
<?php
$nome = 'Jack O\'Neill & "Bob" <3';
// <script>var nome = "{{ $nome }}";</script> produz:
var nome = "Jack O'Neill & "Bob" <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.