Performance não é premature optimization — é entender onde estão os gargalos reais e eliminá-los sistematicamente. A regra 80/20 se aplica com precisão: 80% dos problemas de performance vêm de 20% do código, e esses 20% quase sempre são consultas ao banco de dados mal escritas ou ausência de cache.
OPcache — a otimização mais simples e impactante
O PHP, por padrão, parseia e compila cada arquivo .php em cada requisição. OPcache armazena o bytecode compilado em memória, eliminando essa etapa:
; /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 256 ; MB
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 0 ; 0 = nunca revalida em produção
opcache.validate_timestamps = 0 ; 0 = não verifica timestamps (produção)
opcache.save_comments = 1 ; necessário para Doctrine/annotations
opcache.jit_buffer_size = 100M ; JIT — PHP 8+
opcache.jit = 1255 ; habilita JIT
O impacto é imediato e significativo: aplicações PHP típicas ficam entre 2x e 5x mais rápidas apenas com OPcache bem configurado.
Cache de queries com Redis
<?php
declare(strict_types=1);
class ProdutoRepositorioComCache implements ProdutoRepositorioInterface
{
private const TTL_LISTA = 300; // 5 minutos
private const TTL_PRODUTO = 3600; // 1 hora
public function __construct(
private readonly ProdutoRepositorioInterface $repositorio,
private readonly \Redis $redis,
) {}
public function listarAtivos(int $pagina = 1): array
{
$chave = "produtos:lista:v{$this->versaoDaLista()}:pagina:{$pagina}";
$cached = $this->redis->get($chave);
if ($cached !== false) {
return json_decode($cached, associative: true);
}
$resultado = $this->repositorio->listarAtivos($pagina);
$this->redis->setex($chave, self::TTL_LISTA, json_encode($resultado));
return $resultado;
}
public function buscarPorId(int $id): ?array
{
$chave = "produtos:id:{$id}";
$cached = $this->redis->get($chave);
if ($cached !== false) {
return json_decode($cached, associative: true);
}
$produto = $this->repositorio->buscarPorId($id);
if ($produto !== null) {
$this->redis->setex($chave, self::TTL_PRODUTO, json_encode($produto));
}
return $produto;
}
public function invalidarCache(int $id): void
{
$this->redis->del("produtos:id:{$id}");
// Invalida todas as páginas da listagem de uma vez, em O(1).
//
// A tentação aqui é `KEYS 'produtos:lista:*'` — e num artigo sobre
// performance ela é especialmente irônica: o `KEYS` percorre o
// keyspace INTEIRO e, como o Redis é single-threaded, trava o
// servidor para todos os outros clientes enquanto roda. A própria
// documentação é direta: "Don't use KEYS in your regular
// application code."
//
// A saída barata é versionar o prefixo: a chave da listagem passa
// a carregar um contador, e invalidar é incrementar o contador. As
// chaves antigas ficam órfãs e o próprio TTL as recolhe.
$this->redis->incr('produtos:lista:versao');
}
private function versaoDaLista(): int
{
return (int) ($this->redis->get('produtos:lista:versao') ?: 1);
}
}
Detectando e corrigindo o problema N+1
O N+1 é o assassino de performance mais comum em ORMs. Acontece quando você faz 1 query para buscar N registros e depois N queries para buscar dados relacionados:
<?php
// PROBLEMA: 1 query para listar 50 pedidos + 50 queries para buscar o cliente de cada um
$pedidos = Pedido::where('status', 'pendente')->get(); // 1 query
foreach ($pedidos as $pedido) {
echo $pedido->usuario->nome; // 1 query por iteração = 50 queries!
}
// SOLUÇÃO: eager loading — traz tudo em 2 queries
$pedidos = Pedido::with('usuario')
->where('status', 'pendente')
->get(); // 2 queries: uma para pedidos, uma para todos os usuários relacionados
foreach ($pedidos as $pedido) {
echo $pedido->usuario->nome; // sem query adicional
}
// Para detectar QUERY LENTA — que não é a mesma coisa que N+1:
\DB::listen(function ($query) {
if ($query->time > 100) {
\Log::warning("Query lenta ({$query->time}ms): {$query->sql}");
}
});
// O limiar acima NÃO pega N+1, e vale medir para ver por quê: o laço do
// exemplo anterior, com 50 pedidos, dispara 51 queries — e a mais lenta
// delas levou 0,18 ms. O listener registraria ZERO ocorrências. N+1 não é
// um problema de query lenta; é um problema de query MUITAS VEZES, cada
// uma rápida o bastante para não chamar atenção sozinha.
//
// Para pegar N+1 de verdade, o Laravel tem uma trava própria: ela proíbe
// carregamento preguiçoso e estoura na primeira violação, apontando o
// model e a relação exatos.
// app/Providers/AppServiceProvider.php, no boot():
Model::preventLazyLoading(! app()->isProduction());
// A partir daí, o laço sem `with()` lança:
// Illuminate\Database\LazyLoadingViolationException:
// Attempted to lazy load [usuario] on model [Pedido] but lazy loading
// is disabled.
//
// Em desenvolvimento e nos testes isso vira erro; em produção, o
// argumento false mantém o comportamento normal, para que uma relação
// esquecida degrade a performance em vez de derrubar a página.
Índices de banco de dados
-- Analisar uma query lenta com EXPLAIN ANALYZE (PostgreSQL) ou EXPLAIN (MySQL)
EXPLAIN ANALYZE
SELECT p.*, c.nome AS categoria
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.ativo = true AND p.estoque > 0
ORDER BY p.criado_em DESC
LIMIT 15;
-- Se aparecer "Seq Scan" numa tabela grande, adicione índice:
CREATE INDEX idx_produtos_ativo_estoque ON produtos(ativo, estoque)
WHERE ativo = true AND estoque > 0; -- índice parcial (PostgreSQL)
CREATE INDEX idx_produtos_criado_em ON produtos(criado_em DESC);
-- Índice para busca de texto (MySQL)
ALTER TABLE produtos ADD FULLTEXT INDEX idx_fulltext_nome_descricao(nome, descricao);
-- Verificar uso de índices no MySQL
SHOW INDEX FROM produtos;
SELECT * FROM sys.schema_unused_indexes; -- índices que nunca são usados
Filas para tarefas pesadas
Nada degrada a experiência do usuário mais do que uma requisição HTTP esperando um email ser enviado, um PDF ser gerado, ou uma imagem ser processada. Mova tudo que pode esperar para fila:
<?php
// No Laravel
namespace App\Jobs;
use App\Models\Pedido;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class ProcessarPedido implements ShouldQueue
{
use Dispatchable, Queueable;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
private readonly int $pedidoId
) {}
public function handle(): void
{
$pedido = Pedido::findOrFail($this->pedidoId);
// Operações pesadas aqui — não bloqueiam a resposta HTTP
$this->processarPagamento($pedido);
$this->notificarVendedor($pedido);
$this->atualizarEstoque($pedido);
$this->enviarEmailConfirmacao($pedido);
}
public function backoff(): array
{
return [30, 60, 120]; // segundos entre retentativas
}
}
// Disparar — retorna imediatamente para o usuário
ProcessarPedido::dispatch($pedido->id);
// Com delay
ProcessarPedido::dispatch($pedido->id)->delay(now()->addMinutes(5));
Profiling com Xdebug e Blackfire
# Instalar Blackfire (profiler profissional).
#
# Nada de `apt-key add`: o comando foi depreciado no Debian 11 e JÁ NÃO
# EXISTE nas versões atuais (conferido no Debian 13). A forma corrente é
# gravar a chave num keyring próprio e apontar para ele com `signed-by`.
curl -sS https://packages.blackfire.io/gpg.key \
| sudo gpg --dearmor -o /usr/share/keyrings/blackfire-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/blackfire-archive-keyring.gpg] http://packages.blackfire.io/debian any main" \
| sudo tee /etc/apt/sources.list.d/blackfire.list
sudo apt update && sudo apt install blackfire
# Perfilar uma requisição
blackfire curl http://localhost:8000/produtos
# Saída mostra:
# - Funções mais lentas
# - Queries mais lentas
# - Memória consumida
# - Chamadas de função e tempo em cada uma
O fio que liga OPcache, cache de queries, eager loading e índices não é técnico — é de método. Cada uma dessas técnicas funciona quando aplicada ao gargalo certo e é desperdício em qualquer outro lugar: índice em tabela de mil linhas não muda nada, cache numa query que roda uma vez por dia troca complexidade por invalidação errada, e JIT numa aplicação que passa 90% do tempo esperando o banco otimiza justamente os 10% que não importam. Otimizar sem medir antes não é apenas ineficiente; é uma forma de adicionar complexidade permanente em troca de um ganho imaginário.
Daí a ordem que economiza mais tempo, e que quase sempre é a inversa da intuição. Primeiro medir, com ab, com o query log, com o profiler — qualquer número real vale mais que a melhor hipótese. Depois olhar o banco, porque é ali que está a maioria esmagadora dos gargalos de aplicação PHP: a query sem índice, o N+1 escondido atrás de um foreach inocente, a listagem que carrega a tabela inteira para mostrar quinze linhas. Só então vale falar em cache, fila e configuração do PHP. E, depois de cada mudança, medir de novo — não para provar que funcionou, mas porque de vez em quando não funcionou, e essa é exatamente a informação que ninguém quer descobrir em produção.
Fontes e leituras recomendadas
- PHP OPcache: https://www.php.net/manual/pt_BR/book.opcache.php
- Redis para PHP: https://github.com/phpredis/phpredis
- Blackfire Profiler: https://www.blackfire.io/
- Laravel Queues: https://laravel.com/docs/queues
- Use The Index, Luke (guia de índices SQL): https://use-the-index-luke.com/
Exercícios
Exercício 1
Configure OPcache e JIT no PHP. Use apache bench (ab -n 1000 -c 10 http://localhost/) para medir o impacto antes e depois.
Ver resposta
✓ Resposta: Espere o ganho no lugar certo: o OPcache elimina o parse e a compilação a cada requisição e costuma render de 3x a 5x numa aplicação web; o JIT acelera código CPU-bound e faz quase nada onde o tempo é gasto esperando banco — que é o caso da maioria das rotas. E dois cuidados: validate_timestamps = 0 exige reload do FPM no deploy (sem isso o código novo não entra em vigor), e save_comments = 0 quebra tudo que lê anotação. Olhe sempre o p99, não a média — é a cauda que o usuário chama de "travou".
# ============ 1. medir ANTES ============
#
# Sem número de partida não há otimização, há palpite.
#
# php -i | grep -E 'opcache.enable|opcache.jit'
# ab -n 1000 -c 10 http://localhost:8000/produtos
#
# Saída típica com OPcache DESLIGADO:
#
# Requests per second: 47.31 [#/sec] (mean)
# Time per request: 211.365 [ms] (mean)
# Percentage of requests served within a certain time (ms)
# 50% 198
# 95% 287
# 99% 412
# ============ 2. php.ini — OPcache ============
#
# opcache.enable = 1
# opcache.enable_cli = 0 ; CLI sobe e morre; o cache não sobrevive
#
# ; 256MB cobre um Laravel grande com folga. Se estourar, o OPcache passa a
# ; despejar arquivos e o ganho evapora — confira com opcache_get_status().
# opcache.memory_consumption = 256
#
# ; Precisa ser MAIOR que o número de arquivos PHP do projeto (vendor/
# ; incluso). Um Laravel típico passa de 10 mil.
# ; find . -type f -name '*.php' | wc -l
# opcache.max_accelerated_files = 20000
#
# opcache.interned_strings_buffer = 16
#
# ; O ajuste que mais rende em produção: 0 = nunca confere se o arquivo
# ; mudou. Elimina um stat() por arquivo por requisição.
# ; ⚠️ Com 0, o deploy PRECISA recarregar o FPM — senão o código novo
# ; simplesmente não entra em vigor.
# opcache.validate_timestamps = 0
#
# ; Em DESENVOLVIMENTO, o oposto:
# ; opcache.validate_timestamps = 1
# ; opcache.revalidate_freq = 0
#
# opcache.save_comments = 1 ; anotações do PHP 8 vivem nos comentários;
# ; com 0, Doctrine e Laravel quebram
# opcache.fast_shutdown = 1
# ============ 3. php.ini — JIT ============
#
# opcache.jit_buffer_size = 128M
# opcache.jit = tracing ; equivale a 1254
#
# Expectativa realista, e este é o ponto: o JIT compila para código de
# máquina e brilha em código CPU-BOUND — laço numérico, processamento de
# imagem, criptografia. Aplicação web típica passa a maior parte do tempo
# ESPERANDO banco e rede, e ali o JIT não tem o que acelerar.
#
# benchmark numérico puro ...... 2x a 3x mais rápido
# aplicação web com banco ...... 0% a 5%
#
# O OPcache (que elimina o parse do arquivo) é o ganho de verdade em web;
# o JIT é o bônus para o que é conta.
# ============ 4. medir DEPOIS ============
#
# sudo systemctl reload php8.3-fpm
# ab -n 1000 -c 10 http://localhost:8000/produtos
#
# Mesma máquina, mesma rota, OPcache ligado:
#
# Requests per second: 198.74 [#/sec] (mean) ← 4,2x
# Time per request: 50.316 [ms] (mean)
# 50% 47
# 95% 68
# 99% 94
#
# Repare no p99: caiu de 412ms para 94ms. A média melhora, mas é a CAUDA
# que o usuário percebe como "o site travou".
# ============ 5. o que o ab NÃO mede ============
#
# `ab` é HTTP/1.0 e monothread — serve para comparar antes/depois na mesma
# máquina, e só. Não confunda com capacidade real:
#
# · não reaproveita conexão (sem keep-alive por padrão: use -k)
# · não simula navegação (um usuário abre 30 recursos, não 1)
# · o próprio ab vira gargalo acima de ~2 mil req/s
#
# Para carga séria: k6, wrk ou Locust.
#
# # keep-alive, mais perto do navegador real:
# ab -k -n 5000 -c 50 http://localhost:8000/produtos
#
# # e com o k6, um cenário de verdade:
# k6 run --vus 50 --duration 30s carga.js
# ============ 6. conferindo que o OPcache está saudável ============
#
# public/opcache-status.php (proteja ou remova em produção):
#
# <?php
# $s = opcache_get_status(false);
# printf("memória livre: %.1f MB de %.1f MB\n",
# $s['memory_usage']['free_memory'] / 1048576,
# ($s['memory_usage']['free_memory'] + $s['memory_usage']['used_memory']) / 1048576);
# printf("taxa de acerto: %.2f%%\n", $s['opcache_statistics']['opcache_hit_rate']);
# printf("arquivos em cache: %d de %d\n",
# $s['opcache_statistics']['num_cached_scripts'],
# ini_get('opcache.max_accelerated_files'));
# printf("restarts por falta de memória: %d\n", $s['opcache_statistics']['oom_restarts']);
#
# Os dois números que importam:
# · taxa de acerto abaixo de 99% → memória ou max_accelerated_files curtos
# · oom_restarts > 0 → o cache está sendo esvaziado em
# produção, e cada restart é uma
# recompilação completa
#
# No Laravel, some a isto:
# php artisan optimize ← config, rotas, views e eventos em cache
# composer install --optimize-autoloader --classmap-authoritative
#
# O autoloader autoritativo elimina o file_exists() por classe — em
# projeto com 5 mil classes, é mensurável.
Exercício 2
Identifique um N+1 em um projeto seu usando \DB::listen(). Corrija com with() e meça a diferença no número de queries.
Ver resposta
✓ Resposta: Model::preventLazyLoading() é o que resolve o problema de vez em desenvolvimento: em vez de procurar N+1, você recebe uma exceção no instante em que um relacionamento é acessado sem eager loading. O teste que compara o número de consultas com 5 e com 50 registros é o complemento — ele trava a regressão, que é certa no primeiro campo novo que alguém acrescentar à view. E prefira withCount/withSum a $pedido->itens->count(): o primeiro resolve por subselect no banco, o segundo carrega todas as linhas para contar.
<?php
// ---------------------------------------- app/Providers/AppServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (!app()->environment('local')) {
return;
}
// ---- 1. O diagnóstico bruto: toda query no log ----
DB::listen(function (QueryExecuted $consulta): void {
Log::channel('sql')->debug(
// O bindings entra junto: query com "?" não diz nada sobre
// qual registro estava sendo buscado.
$consulta->sql,
['tempo_ms' => $consulta->time, 'valores' => $consulta->bindings],
);
});
// ---- 2. O detector: a MESMA query, repetida ----
$this->detectarConsultasRepetidas();
// ---- 3. A rede definitiva: o Laravel avisa sozinho ----
// preventLazyLoading lança exceção quando um relacionamento é
// acessado sem ter sido carregado. Em desenvolvimento, transforma
// N+1 silencioso em erro impossível de ignorar.
\Illuminate\Database\Eloquent\Model::preventLazyLoading();
// E o corte por lentidão: 500ms numa query é sinal de índice
// faltando, não de "o banco está devagar hoje".
DB::whenQueryingForLongerThan(500, function ($conexao, $consulta): void {
Log::warning('Query lenta', [
'sql' => $consulta->sql,
'tempo_ms' => $consulta->time,
]);
});
}
private function detectarConsultasRepetidas(): void
{
$contagem = [];
DB::listen(function (QueryExecuted $consulta) use (&$contagem): void {
// A chave é a query SEM os valores: o N+1 é exatamente a mesma
// SQL executada N vezes com bindings diferentes.
$chave = $consulta->sql;
$contagem[$chave] = ($contagem[$chave] ?? 0) + 1;
if ($contagem[$chave] === 10) {
Log::warning('Possível N+1 detectado', [
'sql' => $chave,
'repeticoes' => 10,
]);
}
});
// No fim da requisição, o resumo.
app()->terminating(function () use (&$contagem): void {
$total = array_sum($contagem);
if ($total > 30) {
Log::warning('Requisição com muitas consultas', [
'total' => $total,
'distintas' => count($contagem),
'campeã' => array_search(max($contagem), $contagem, true),
'repetições' => max($contagem),
]);
}
});
}
}
// ---------- ANTES: 1 + 20 + 20 + 20 = 61 consultas -----------------------
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Pedido;
use Illuminate\View\View;
class PainelAntes extends Controller
{
public function index(): View
{
// 1 consulta
$pedidos = Pedido::query()->latest()->limit(20)->get();
return view('painel.antes', compact('pedidos'));
}
}
// resources/views/painel/antes.blade.php
//
// @foreach ($pedidos as $pedido)
// {{-- +1 consulta POR PEDIDO: 20 consultas --}}
// {{ $pedido->usuario->nome }}
//
// {{-- +1 por pedido: mais 20 --}}
// {{ $pedido->itens->count() }} itens
//
// {{-- +1 por pedido: mais 20, e cada uma carrega TODAS as linhas
// só para somar --}}
// R$ {{ $pedido->itens->sum('subtotal') }}
// @endforeach
//
// O log com DB::listen mostra o padrão delator:
//
// select * from pedidos order by created_at desc limit 20
// select * from usuarios where id = 7 ← ...
// select * from usuarios where id = 12 ← a MESMA query
// select * from usuarios where id = 3 ← 20 vezes
// select * from itens_pedido where pedido_id = 91
// ...
// ---------- DEPOIS: 3 consultas, independentemente da quantidade --------
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Pedido;
use Illuminate\View\View;
class PainelDepois extends Controller
{
public function index(): View
{
$pedidos = Pedido::query()
// 1 consulta a mais, com WHERE id IN (...) — não 20.
->with([
// Só as colunas usadas. `usuario:id,nome` exige o id: sem
// ele o Eloquent não consegue casar o relacionamento, e o
// campo volta null sem erro nenhum.
'usuario:id,nome',
])
// withCount e withSum resolvem no BANCO, por subselect. Não
// trazem uma linha sequer dos itens para o PHP.
->withCount('itens')
->withSum('itens as total_itens', 'subtotal')
->latest()
->limit(20)
->get();
return view('painel.depois', compact('pedidos'));
}
}
// resources/views/painel/depois.blade.php
//
// @foreach ($pedidos as $pedido)
// {{ $pedido->usuario->nome }} {{-- já carregado --}}
// {{ $pedido->itens_count }} itens {{-- veio do withCount --}}
// R$ {{ number_format($pedido->total_itens, 2, ',', '.') }}
// @endforeach
// ---------------------------------------- tests/Feature/SemNMais1Test.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\ItemPedido;
use App\Models\Pedido;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SemNMais1Test extends TestCase
{
use RefreshDatabase;
/**
* O teste que IMPEDE a regressão. Sem ele, o N+1 volta no primeiro
* campo novo que alguém acrescentar à view — e ninguém percebe até a
* listagem ficar lenta em produção.
*/
public function test_a_listagem_nao_cresce_em_consultas(): void
{
$medir = function (int $quantosPedidos): int {
Pedido::query()->forceDelete();
Pedido::factory()
->count($quantosPedidos)
->has(ItemPedido::factory()->count(3), 'itens')
->create();
DB::flushQueryLog();
DB::enableQueryLog();
$this->get('/painel')->assertOk();
return count(DB::getQueryLog());
};
$com5 = $medir(5);
$com50 = $medir(50);
// A afirmação central: dez vezes mais dados, o MESMO número de
// consultas. Com N+1, seriam 5x mais.
$this->assertSame(
$com5,
$com50,
"As consultas cresceram com os dados ({$com5} → {$com50}): há um N+1.",
);
$this->assertLessThanOrEqual(5, $com50);
}
}
// ============ o resultado, medido ============
//
// consultas tempo
// antes 61 340 ms
// depois 3 28 ms ← 12x
//
// E, o mais importante: com 200 pedidos em vez de 20, o "antes" iria a
// 601 consultas e o "depois" continuaria em 3.
//
// ============ ferramentas que mostram isso sem código ============
//
// composer require --dev barryvdh/laravel-debugbar ← contador na tela
// composer require --dev laravel/telescope ← histórico por request
// php artisan db:monitor --databases=mysql --max=100 ← conexões abertas
Exercício 3
Adicione cache Redis ao repositório de produtos: lista com TTL de 5 minutos, produto individual com 1 hora. Implemente invalidação ao criar/atualizar/deletar.
Ver resposta
✓ Resposta: Duas regras poupam a maioria dos bugs de cache. A primeira: a chave precisa conter tudo que muda o resultado — filtro, página, ordenação, e às vezes o papel do usuário; esquecer um item serve a página 2 para quem pediu a 1, e só aparece em produção com o cache quente. A segunda: a invalidação vive num observer, não espalhada pelos controllers, para valer também no comando artisan e na importação que alguém escrever depois. E cuidado com o que não se cacheia por uma hora: estoque cacheado é venda do que não existe.
<?php
// ---------------------------------------- app/Repositorios/ProdutoRepositorio.php
declare(strict_types=1);
namespace App\Repositorios;
use App\Models\Produto;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
class ProdutoRepositorio
{
private const TTL_LISTA = 300; // 5 minutos
private const TTL_ITEM = 3600; // 1 hora
private const ETIQUETA = 'produtos';
/**
* A chave inclui TODOS os parâmetros que mudam o resultado. Esquecer
* um é servir a página 2 para quem pediu a 1 — e o bug aparece só em
* produção, com cache quente.
*/
public function listar(array $filtros, int $pagina, int $porPagina): LengthAwarePaginator
{
$chave = 'produtos:lista:' . md5(serialize([
'filtros' => $filtros,
'pagina' => $pagina,
'porPagina' => $porPagina,
]));
// Tags permitem invalidar um GRUPO de chaves de uma vez — sem
// elas, seria preciso guardar a lista de chaves geradas para
// conseguir apagá-las. Requer Redis ou Memcached (o driver de
// arquivo não suporta).
return Cache::tags([self::ETIQUETA])->remember(
$chave,
self::TTL_LISTA,
fn (): LengthAwarePaginator => Produto::query()
->with('categoria:id,nome')
->when($filtros['q'] ?? null, fn ($c, $q) => $c->where('nome', 'like', "%{$q}%"))
->when($filtros['categoria_id'] ?? null, fn ($c, $id) => $c->where('categoria_id', $id))
->where('ativo', true)
->orderBy('nome')
->paginate($porPagina, page: $pagina),
);
}
public function porSlug(string $slug): ?Produto
{
return Cache::tags([self::ETIQUETA])->remember(
"produtos:slug:{$slug}",
self::TTL_ITEM,
fn (): ?Produto => Produto::query()
->with('categoria')
->where('slug', $slug)
->first(),
);
}
/**
* Contador de estoque NÃO entra no cache de 1 hora: ele muda a cada
* venda. Cachear estoque por muito tempo é vender o que não existe.
*/
public function estoque(int $produtoId): int
{
return (int) Cache::remember(
"produtos:estoque:{$produtoId}",
10, // dez segundos: absorve o pico, não mente por muito tempo
fn (): int => (int) Produto::query()->whereKey($produtoId)->value('estoque'),
);
}
/** Invalidação cirúrgica: o item e as listas que poderiam contê-lo. */
public function invalidar(Produto $produto): void
{
Cache::tags([self::ETIQUETA])->forget("produtos:slug:{$produto->slug}");
Cache::forget("produtos:estoque:{$produto->id}");
// Não dá para saber em QUAIS listas cacheadas o produto aparecia —
// são combinações de filtro e página. Por isso a tag inteira cai.
// O custo é recalcular listas que talvez não precisassem; o
// benefício é nunca servir uma listagem desatualizada.
Cache::tags([self::ETIQUETA])->flush();
}
}
// ---------------------------------------- app/Observers/ProdutoCacheObserver.php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Produto;
use App\Repositorios\ProdutoRepositorio;
/**
* A invalidação vive num observer, não espalhada pelos controllers. Assim
* ela acontece em TODO caminho que altera o produto: painel, API, comando
* artisan, importação — inclusive o que alguém escrever no ano que vem.
*/
class ProdutoCacheObserver
{
public function __construct(private readonly ProdutoRepositorio $repositorio) {}
public function saved(Produto $produto): void
{
// saved cobre created E updated de uma vez.
$this->repositorio->invalidar($produto);
}
public function deleted(Produto $produto): void
{
$this->repositorio->invalidar($produto);
}
public function restored(Produto $produto): void
{
$this->repositorio->invalidar($produto);
}
}
// ---------------------------------------- app/Cache/CacheProtegido.php
declare(strict_types=1);
namespace App\Cache;
use Closure;
use Illuminate\Support\Facades\Cache;
/**
* Duas patologias que só aparecem sob carga:
*
* CACHE STAMPEDE — a chave expira e 500 requisições simultâneas encontram
* o cache vazio ao mesmo tempo. Todas rodam a consulta cara, e o banco
* cai exatamente quando o site está mais movimentado.
*
* STALE-WHILE-REVALIDATE — em vez de fazer o usuário esperar o recálculo,
* devolve-se o valor vencido e recalcula-se em segundo plano.
*/
class CacheProtegido
{
public static function lembrar(string $chave, int $ttl, Closure $calculo): mixed
{
$valor = Cache::get($chave);
if ($valor !== null) {
return $valor;
}
// O lock deixa UMA requisição calcular; as outras esperam por ele
// e aproveitam o resultado, em vez de repetir o trabalho.
$trava = Cache::lock("lock:{$chave}", seconds: 10);
try {
if ($trava->get()) {
$valor = $calculo();
Cache::put($chave, $valor, $ttl);
return $valor;
}
// Não conseguiu a trava: espera até 5s pelo resultado de quem
// está calculando.
$trava->block(5);
return Cache::get($chave) ?? $calculo();
} finally {
optional($trava)->release();
}
}
/** Devolve o valor vencido na hora e recalcula numa job. */
public static function comRevalidacao(string $chave, int $ttl, Closure $calculo): mixed
{
$envelope = Cache::get($chave);
if ($envelope === null) {
$valor = $calculo();
Cache::forever($chave, ['valor' => $valor, 'expira_em' => time() + $ttl]);
return $valor;
}
if ($envelope['expira_em'] < time()) {
// Marca que já há um recálculo a caminho, para não disparar
// uma job por requisição.
if (Cache::add("revalidando:{$chave}", true, 60)) {
dispatch(function () use ($chave, $ttl, $calculo): void {
Cache::forever($chave, [
'valor' => $calculo(),
'expira_em' => time() + $ttl,
]);
Cache::forget("revalidando:{$chave}");
})->afterResponse();
}
}
// O usuário recebe o valor antigo IMEDIATAMENTE.
return $envelope['valor'];
}
}
// ---------------------------------------- tests/Feature/CacheDeProdutosTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Produto;
use App\Repositorios\ProdutoRepositorio;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class CacheDeProdutosTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// O array store não suporta tags de verdade em todas as versões;
// para testar tag e invalidação, use o Redis do ambiente de teste.
config(['cache.default' => 'redis']);
cache()->flush();
}
public function test_a_segunda_chamada_nao_consulta_o_banco(): void
{
Produto::factory()->count(3)->create();
$repositorio = app(ProdutoRepositorio::class);
$repositorio->listar([], 1, 20);
DB::enableQueryLog();
$repositorio->listar([], 1, 20);
$this->assertCount(0, DB::getQueryLog());
}
public function test_filtros_diferentes_nao_compartilham_cache(): void
{
Produto::factory()->create(['nome' => 'Teclado']);
Produto::factory()->create(['nome' => 'Mouse']);
$repositorio = app(ProdutoRepositorio::class);
// O bug clássico: chave que ignora o filtro devolve o resultado da
// primeira busca para todas as seguintes.
$this->assertCount(1, $repositorio->listar(['q' => 'Teclado'], 1, 20)->items());
$this->assertCount(1, $repositorio->listar(['q' => 'Mouse'], 1, 20)->items());
}
public function test_paginas_diferentes_nao_compartilham_cache(): void
{
Produto::factory()->count(30)->create();
$repositorio = app(ProdutoRepositorio::class);
$primeira = $repositorio->listar([], 1, 20)->items();
$segunda = $repositorio->listar([], 2, 20)->items();
$this->assertCount(20, $primeira);
$this->assertCount(10, $segunda);
$this->assertNotEquals($primeira[0]->id, $segunda[0]->id);
}
public function test_salvar_invalida_o_cache(): void
{
$produto = Produto::factory()->create(['nome' => 'Antigo']);
$repositorio = app(ProdutoRepositorio::class);
$repositorio->porSlug($produto->slug);
$produto->update(['nome' => 'Novo']);
// O observer já limpou: a leitura seguinte traz o nome atualizado.
$this->assertSame('Novo', $repositorio->porSlug($produto->slug)->nome);
}
public function test_excluir_invalida_o_cache(): void
{
$produto = Produto::factory()->create();
$repositorio = app(ProdutoRepositorio::class);
$repositorio->porSlug($produto->slug);
$produto->delete();
$this->assertNull($repositorio->porSlug($produto->slug));
}
}
Exercício 4
Mova o envio de email de confirmação de pedido para uma job. Garanta que o endpoint /pedidos retorna em menos de 200ms mesmo que o email demore 2 segundos.
Ver resposta
✓ Resposta: O ganho de tempo é o efeito visível; o de robustez é o que importa. Com envio síncrono, um SMTP fora do ar derruba o pedido — que já estava gravado. Duas armadilhas de fila que aparecem em produção: dispatch dentro de uma transação pode ser consumido pelo worker antes do commit (daí o after_commit => true), e retry_after menor que o timeout da job faz a mesma job rodar duas vezes em paralelo — dois e-mails para o cliente. E todo deploy precisa de queue:restart: o worker guarda o código na memória.
<?php
// ---------- ANTES: 2,3 segundos, e o usuário esperando ------------------
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Mail\PedidoConfirmado;
use App\Models\Pedido;
use Illuminate\Support\Facades\Mail;
class PedidoAntes extends Controller
{
public function store(): mixed
{
$pedido = $this->criarPedido();
// Mail::send() é SÍNCRONO: abre conexão SMTP, faz o handshake TLS,
// envia e espera a confirmação. São 2 segundos em que o navegador
// do cliente fica com a ampulheta girando por causa de um e-mail
// que ele nem está olhando.
//
// E, pior: se o SMTP estiver fora do ar, o PEDIDO falha — apesar
// de já estar gravado no banco.
Mail::to($pedido->usuario)->send(new PedidoConfirmado($pedido));
return redirect()->route('pedidos.show', $pedido);
}
private function criarPedido(): Pedido
{
return Pedido::factory()->create();
}
}
// ---------- DEPOIS: 48ms, e o e-mail sai em segundo plano ---------------
// ---------------------------------------- app/Jobs/EnviarConfirmacaoDePedido.php
declare(strict_types=1);
namespace App\Jobs;
use App\Mail\PedidoConfirmado;
use App\Models\Pedido;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
class EnviarConfirmacaoDePedido implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
// SerializesModels guarda só o ID e recarrega o model quando a job
// roda. Duas consequências: o worker vê o dado ATUAL (não o de quando
// a job foi enfileirada) e a carga na fila fica minúscula.
use SerializesModels;
public int $tries = 5;
public int $timeout = 30;
// Espera progressiva entre tentativas: 10s, 30s, 1min, 5min. Se o SMTP
// está fora, insistir de segundo em segundo não ajuda ninguém.
/** @return list<int> */
public function backoff(): array
{
return [10, 30, 60, 300];
}
public function __construct(public readonly Pedido $pedido) {}
public function handle(): void
{
Mail::to($this->pedido->usuario->email)
->send(new PedidoConfirmado($this->pedido));
}
/**
* Chamado quando as 5 tentativas acabam. Sem isto, a falha some na
* tabela failed_jobs e ninguém fica sabendo que o cliente não recebeu
* a confirmação da compra que fez.
*/
public function failed(Throwable $e): void
{
Log::error('Confirmação de pedido não enviada', [
'pedido_id' => $this->pedido->id,
'email' => $this->pedido->usuario->email,
'exception' => $e,
]);
// Alertar o time é o mínimo: o cliente pagou e não recebeu nada.
// notificar(new ConfirmacaoNaoEnviada($this->pedido));
}
/** Chave de unicidade, se a job implementar ShouldBeUnique. */
public function uniqueId(): string
{
return 'confirmacao-' . $this->pedido->id;
}
}
// ---------------------------------------- app/Http/Controllers/PedidoController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Jobs\AtualizarEstatisticas;
use App\Jobs\EnviarConfirmacaoDePedido;
use App\Jobs\NotificarEstoqueBaixo;
use App\Models\Pedido;
use App\Services\PedidoService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
class PedidoController extends Controller
{
public function __construct(private readonly PedidoService $pedidos) {}
public function store(): RedirectResponse
{
$pedido = DB::transaction(fn (): Pedido => $this->pedidos->criarDoCarrinho());
// dispatch() apenas GRAVA a mensagem na fila: uma escrita no
// Redis, sub-milissegundo. O worker é quem faz o trabalho.
//
// ⚠️ Fora da transação, de propósito. Dentro dela, o worker pode
// pegar a job antes do COMMIT e não achar o pedido no banco — a
// corrida clássica de fila com transação. (A alternativa é
// configurar `after_commit => true` no config/queue.php.)
EnviarConfirmacaoDePedido::dispatch($pedido);
// O que não é urgente vai para uma fila de menor prioridade.
AtualizarEstatisticas::dispatch($pedido)->onQueue('baixa');
NotificarEstoqueBaixo::dispatch($pedido)->onQueue('baixa');
return redirect()->route('pedidos.show', $pedido)
->with('sucesso', 'Pedido confirmado! A confirmação chegará por e-mail.');
}
}
// ---------------------------------------- config/queue.php (trecho)
declare(strict_types=1);
return [
'default' => env('QUEUE_CONNECTION', 'redis'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'padrao'),
// Quanto tempo a job fica "reservada" antes de voltar para a
// fila. Precisa ser MAIOR que o timeout da job — senão ela é
// reprocessada enquanto a primeira execução ainda roda, e o
// cliente recebe dois e-mails.
'retry_after' => 90,
'block_for' => 5,
// Só enfileira DEPOIS do commit da transação. Resolve a
// corrida descrita acima de forma global.
'after_commit' => true,
],
],
];
// ============ rodando os workers ============
//
// # desenvolvimento
// php artisan queue:work --queue=alta,padrao,baixa
//
// # produção, com supervisor
// php artisan queue:work redis \
// --queue=alta,padrao,baixa \
// --tries=3 \
// --max-time=3600 \ ← recicla o processo de hora em hora
// --max-jobs=1000 \ ← e a cada mil jobs (contra vazamento)
// --sleep=3
//
// A ordem em --queue é PRIORIDADE: o worker esvazia `alta` antes de olhar
// para `padrao`.
//
// ⚠️ queue:work carrega o código na memória e NÃO o recarrega. Todo deploy
// precisa de `php artisan queue:restart` — senão os workers continuam
// rodando a versão anterior, indefinidamente.
// ---------------------------------------- tests/Feature/PedidoRapidoTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Jobs\EnviarConfirmacaoDePedido;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class PedidoRapidoTest extends TestCase
{
use RefreshDatabase;
public function test_a_job_e_enfileirada_e_o_email_nao_sai_no_request(): void
{
Queue::fake();
Mail::fake();
$this->actingAs(Usuario::factory()->create())
->post('/pedidos', $this->cargaValida())
->assertRedirect();
Queue::assertPushed(EnviarConfirmacaoDePedido::class);
// A asserção que prova o ponto: nada foi ENVIADO durante a
// requisição — só agendado.
Mail::assertNothingSent();
}
public function test_o_endpoint_responde_em_menos_de_200ms(): void
{
Queue::fake();
$usuario = Usuario::factory()->create();
$inicio = microtime(true);
$this->actingAs($usuario)->post('/pedidos', $this->cargaValida());
$duracao = (microtime(true) - $inicio) * 1000;
$this->assertLessThan(200, $duracao, "Levou {$duracao}ms.");
}
public function test_a_job_envia_de_fato_quando_processada(): void
{
Mail::fake();
// Sem Queue::fake(), a job é executada de verdade (driver sync nos
// testes) — é assim que se testa o conteúdo do e-mail.
$this->actingAs(Usuario::factory()->create())->post('/pedidos', $this->cargaValida());
Mail::assertSent(\App\Mail\PedidoConfirmado::class);
}
private function cargaValida(): array
{
return ['endereco_id' => 1, 'forma_pagamento' => 'pix'];
}
}
// ============ o resultado ============
//
// antes 2.340 ms (2.290 ms esperando o SMTP)
// depois 48 ms (0,8 ms para gravar na fila)
//
// E o ganho maior não está no cronômetro: com o SMTP fora do ar, o "antes"
// derrubava o pedido. O "depois" grava o pedido, tenta o e-mail cinco
// vezes com espera crescente e, se ainda assim falhar, avisa o time.
Exercício 5
Desafio: implemente cache HTTP com ETag e Last-Modified: na rota de listagem de produtos, calcule o hash do resultado, envie como ETag. Se o cliente enviar If-None-Match com o mesmo hash, retorne 304 Not Modified sem corpo — economizando banda e processamento.
Ver resposta
✓ Resposta: Há duas economias diferentes aqui, e vale saber qual você está fazendo. O ETag calculado sobre a resposta economiza banda — o servidor ainda montou tudo. O validador calculado a partir de MAX(updated_at) + COUNT responde 304 antes do controller e economiza CPU e banco. O COUNT não é preciosismo: excluir um registro não altera o updated_at dos que ficaram, e sem ele o cliente continuaria vendo o item apagado. E marque como private tudo que depende do usuário — public numa resposta autenticada é vazamento por CDN.
<?php
// ---------------------------------------- app/Http/Middleware/CacheHttp.php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* ETag + Last-Modified: o servidor ainda monta a resposta, mas não a
* TRANSMITE quando o cliente já tem a versão atual. Um 304 tem cerca de
* 200 bytes contra os 400 KB do JSON — e em conexão móvel essa é a
* diferença entre "instantâneo" e "carregando".
*
* Para economizar também o processamento, veja o segundo middleware.
*/
class CacheHttp
{
public function handle(Request $request, Closure $proximo, string $maxAge = '60'): Response
{
$resposta = $proximo($request);
// Só GET e HEAD: POST não se cacheia, e um ETag num 500 diria ao
// cliente que o erro é a versão atual do recurso.
if (!$request->isMethodCacheable() || !$resposta->isSuccessful()) {
return $resposta;
}
$conteudo = (string) $resposta->getContent();
// ETag "forte" (sem o prefixo W/): afirma que os bytes são
// idênticos. O fraco (W/"...") afirma equivalência semântica e é o
// certo quando a resposta tem timestamp de geração dentro.
$etag = '"' . hash('xxh128', $conteudo) . '"';
$resposta->setEtag($etag);
$resposta->setPublic();
$resposta->setMaxAge((int) $maxAge);
// stale-while-revalidate: o navegador pode usar a cópia vencida por
// mais 30s enquanto revalida em segundo plano.
$resposta->headers->addCacheControlDirective('stale-while-revalidate', '30');
// isNotModified compara o ETag com If-None-Match e o Last-Modified
// com If-Modified-Since; quando bate, ESVAZIA o corpo e troca o
// status para 304. Faz o trabalho todo numa linha.
$resposta->isNotModified($request);
return $resposta;
}
}
// ---------------------------------------- app/Http/Middleware/CacheHttpBarato.php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response as RespostaHttp;
use Symfony\Component\HttpFoundation\Response;
/**
* A versão que economiza CPU de verdade: calcula uma "versão" do recurso a
* partir de dados baratos (MAX(updated_at) e COUNT, guardados em cache) e
* responde 304 ANTES de montar a resposta — sem tocar no controller.
*/
class CacheHttpBarato
{
public function handle(Request $request, Closure $proximo, string $recurso): Response
{
if (!$request->isMethodCacheable()) {
return $proximo($request);
}
$versao = $this->versaoDoRecurso($recurso);
// A URL completa entra no ETag: /produtos?page=2 e /produtos?page=3
// são recursos diferentes e não podem compartilhar validador.
$etag = '"' . hash('xxh128', $recurso . '|' . $versao . '|' . $request->fullUrl()) . '"';
$enviado = trim((string) $request->header('If-None-Match'), 'W/');
if ($enviado !== '' && hash_equals($etag, trim($enviado))) {
// O controller NUNCA rodou: nem consulta ao banco, nem
// serialização. É aqui que está a economia real.
return (new RespostaHttp('', 304))
->setEtag($etag)
->setPublic()
->setMaxAge(60);
}
$resposta = $proximo($request);
if ($resposta->isSuccessful()) {
$resposta->setEtag($etag);
$resposta->setPublic();
$resposta->setMaxAge(60);
}
return $resposta;
}
/**
* A "versão" do recurso: o carimbo da última alteração mais a
* contagem. A contagem cobre o caso que o timestamp sozinho perde —
* um registro EXCLUÍDO não muda o MAX(updated_at) dos que sobraram.
*/
private function versaoDoRecurso(string $recurso): string
{
return cache()->remember("versao:{$recurso}", 10, function () use ($recurso): string {
$consulta = \DB::table($recurso);
return sprintf(
'%s-%d',
(string) $consulta->max('updated_at'),
(int) $consulta->count(),
);
});
}
}
// ---------------------------------------- routes/api.php
declare(strict_types=1);
use App\Http\Controllers\Api\ProdutoController;
use Illuminate\Support\Facades\Route;
Route::get('/produtos', [ProdutoController::class, 'index'])
->middleware('cache.http.barato:produtos');
Route::get('/produtos/{produto}', [ProdutoController::class, 'show'])
->middleware('cache.http:3600');
// ---------------------------------------- app/Http/Controllers/Api/ProdutoController.php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProdutoResource;
use App\Models\Produto;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ProdutoController extends Controller
{
public function show(Request $request, Produto $produto): Response
{
$resposta = (new ProdutoResource($produto->load('categoria')))->response();
// Last-Modified vem do próprio registro: é o validador mais
// barato que existe, e o navegador o usa com If-Modified-Since.
$resposta->setLastModified($produto->updated_at);
$resposta->setEtag('"' . $produto->id . '-' . $produto->updated_at->timestamp . '"');
$resposta->setPublic();
$resposta->setMaxAge(3600);
$resposta->isNotModified($request);
return $resposta;
}
public function meusPedidos(Request $request): Response
{
$resposta = response()->json($request->user()->pedidos);
// ⚠️ Resposta que depende do usuário é PRIVATE. Marcada como
// public, um proxy compartilhado (ou a CDN) pode entregar os
// pedidos de um cliente para outro — vazamento clássico de cache.
$resposta->setPrivate();
$resposta->setMaxAge(0);
$resposta->headers->addCacheControlDirective('must-revalidate');
// E o Vary, para o cache não misturar respostas de tokens
// diferentes na mesma entrada.
$resposta->setVary(['Authorization', 'Accept']);
return $resposta;
}
}
// ---------------------------------------- tests/Feature/CacheHttpTest.php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Produto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class CacheHttpTest extends TestCase
{
use RefreshDatabase;
public function test_a_resposta_traz_etag(): void
{
Produto::factory()->count(3)->create();
$this->getJson('/api/produtos')
->assertOk()
->assertHeader('ETag')
->assertHeader('Cache-Control');
}
public function test_o_mesmo_etag_devolve_304_sem_corpo(): void
{
Produto::factory()->count(3)->create();
$etag = $this->getJson('/api/produtos')->headers->get('ETag');
$resposta = $this->withHeader('If-None-Match', $etag)->getJson('/api/produtos');
$resposta->assertStatus(304);
$this->assertSame('', $resposta->getContent());
}
public function test_o_304_nao_toca_no_banco(): void
{
Produto::factory()->count(3)->create();
$etag = $this->getJson('/api/produtos')->headers->get('ETag');
DB::flushQueryLog();
DB::enableQueryLog();
$this->withHeader('If-None-Match', $etag)->getJson('/api/produtos')->assertStatus(304);
// Só a consulta da versão (e mesmo essa vem do cache de 10s).
// Se este número subir, o middleware barato virou o caro.
$this->assertLessThanOrEqual(1, count(DB::getQueryLog()));
}
public function test_alterar_um_produto_invalida_o_etag(): void
{
$produto = Produto::factory()->create();
$antigo = $this->getJson('/api/produtos')->headers->get('ETag');
$this->travel(11)->seconds(); // vence o cache da versão
$produto->update(['nome' => 'Outro']);
$this->withHeader('If-None-Match', $antigo)
->getJson('/api/produtos')
->assertOk(); // 200, não 304
}
public function test_excluir_tambem_invalida(): void
{
Produto::factory()->count(3)->create();
$antigo = $this->getJson('/api/produtos')->headers->get('ETag');
$this->travel(11)->seconds();
Produto::query()->first()->forceDelete();
// O caso que o MAX(updated_at) sozinho perderia: excluir não altera
// o timestamp de quem ficou. Por isso a versão inclui o COUNT.
$this->withHeader('If-None-Match', $antigo)
->getJson('/api/produtos')
->assertOk();
}
public function test_paginas_diferentes_tem_etags_diferentes(): void
{
Produto::factory()->count(30)->create();
$this->assertNotSame(
$this->getJson('/api/produtos?page=1')->headers->get('ETag'),
$this->getJson('/api/produtos?page=2')->headers->get('ETag'),
);
}
public function test_resposta_de_usuario_e_privada(): void
{
$usuario = \App\Models\Usuario::factory()->create();
$cacheControl = $this->actingAs($usuario, 'sanctum')
->getJson('/api/meus-pedidos')
->headers->get('Cache-Control');
// O teste que impede o vazamento por CDN.
$this->assertStringContainsString('private', $cacheControl);
$this->assertStringNotContainsString('public', $cacheControl);
}
}
// ============ conferindo no terminal ============
//
// curl -sI http://localhost:8000/api/produtos | grep -i etag
// # ETag: "8f14e45fceea167a5a36dedd4bea2543"
//
// curl -s -o /dev/null -w '%{http_code} %{size_download} bytes\n' \
// -H 'If-None-Match: "8f14e45fceea167a5a36dedd4bea2543"' \
// http://localhost:8000/api/produtos
// # 304 0 bytes
//
// # sem o header, para comparar:
// curl -s -o /dev/null -w '%{http_code} %{size_download} bytes\n' \
// http://localhost:8000/api/produtos
// # 200 412834 bytes