Performance e Otimização

Performance e Otimização

OPcache, cache de queries no Redis, o problema N+1, índices, filas e profiling — com a medição de cada ganho em vez da promessa. Mostra por que um alerta de query lenta nunca acusa um N+1, e qual comando do Redis a própria documentação proíbe em código de aplicação.
PHP

• • 36 min de leitura

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

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
Comentários

Mais em PHP

Testes Automatizados com PHPUnit e Pest
Testes Automatizados com PHPUnit e Pest

PHPUnit e Pest lado a lado, mocks, testes de integração com banco e testes de…

Sessions e Cookies
Sessions e Cookies

O que fica guardado no navegador, o que fica no servidor e por que trocar isso…

Exceções Avançadas
Exceções Avançadas

Uma hierarquia de exceções bem desenhada diz ao chamador o que ele pode tratar…