Destino Dev

Blog

ETags e cache condicional em APIs Laravel (PHP 8.3): reduzir banda e latência

Por John Pires · 20 de março de 2026

1) Motivacao e trade-offs

1.1 Por que ETags importam em APIs modernas

ETags permitem que uma API diga ao cliente "minha representação do recurso não mudou desde a última vez que você consultou" sem enviar o payload completo. Para APIs REST que retornam JSON grandes ou coleções, isso reduz uso de banda e latência percebida. Em mobile ou conexões móveis, a diferença entre 200 com corpo e 304 sem corpo é perceptível.

Além da redução de dados, ETags permitem combinar semântica de versionamento leve com HTTP condicional: o servidor decide se um recurso foi alterado e pode responder 304 Not Modified. Isso é simples de integrar com clientes HTTP que respeitam cabeçalhos padrão (If-None-Match / If-Modified-Since).

1.2 Trade-offs e custos

Calcular fingerprints custa CPU e memória. Gerar um hash baseado no corpo da resposta exige serializar e hashear — isso pode ser custoso em respostas muito grandes ou com alta taxa de requisições. Alternativa: usar metadados (timestamps, contadores, version columns) para gerar fingerprints mais baratos.

Outro trade-off é a validade do ETag em presença de compressão e proxies: o ETag deve representar a entidade efetiva. Se o servidor fizer gzip dinamicamente, ETag pode divergir entre versões comprimidas e não comprimidas (use ETag fraca ou calcule sobre a representação canônica).

Prática: prefira ETags gerados a partir de metadados (updated_at, count, checksum armazenado) quando possível; reserve hashing do corpo para casos simples.

2) Fundamentos tecnicos

2.1 Cabeçalhos HTTP relevantes

Para cache condicional você precisa entender:

  • ETag: identificador da representação. Pode ser forte ("v1") ou fraco (W/"v1").
  • If-None-Match: enviado pelo cliente, contém ETags conhecidas. Se algum corresponder, servidor retorna 304.
  • Last-Modified e If-Modified-Since: alternativa baseada em timestamp; menos precisa em mudanças rápidas.
  • Cache-Control e Vary: instruem caches intermediários; Vary é crítico quando resposta depende de cabeçalhos (Authorization, Accept-Encoding).

2.2 Estratégias para gerar fingerprints

Principais estratégias:

  1. Hash do corpo (SHA1/SHA256): simples e preciso, CPU-intenso.
  2. Combinação de metadados: updated_at + count + relacionamento_checksum. Mais rápida, requer disciplina no modelo.
  3. Fingerprint armazenado (precomputed): manter checksum atualizado ao salvar/alterar recursos (ex: coluna etag_token ou Redis). Excelente performance em leitura.

Escolha depende de perfil: alta taxa de leitura -> precompute; respostas pequenas -> hash do corpo aceitável.

3) Exemplo 1: caso simples

3.1 Contexto e objetivo

Exemplo direto: API que retorna um recurso singular (UserResource) e gera ETag com hash do JSON. Ideal para endpoints que não são acionados em alta frequência ou que retornam payloads razoáveis.

3.2 Implementação minimal em middleware

Antes do código: o middleware captura a resposta, calcula SHA1 do conteúdo e adiciona cabeçalho ETag. Se o cliente enviar If-None-Match igual, retorna 304 sem corpo.

<?php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ETagResponseMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        /** @var Response $response */
        $response = $next($request);

        // Só para respostas 200 com corpo JSON
        if ($response->getStatusCode() !== 200 || stripos($response->headers->get('Content-Type'), 'application/json') === false) {
            return $response;
        }

        $body = $response->getContent();
        $etag = '"' . sha1($body) . '"';

        $ifNoneMatch = $request->headers->get('if-none-match');
        if ($ifNoneMatch && trim($ifNoneMatch) === $etag) {
            $notModified = new Response('', 304);
            // Mantenha os cabeçalhos de cache
            $notModified->headers->set('ETag', $etag);
            $notModified->headers->set('Cache-Control', 'private, max-age=0, must-revalidate');
            return $notModified;
        }

        $response->headers->set('ETag', $etag);
        $response->headers->set('Cache-Control', 'private, max-age=0, must-revalidate');

        return $response;
    }
}

Depois do código: registre o middleware em app/Http/Kernel.php (route middleware) e aplique nas rotas API que precisam de ETag.

<?php
// app/Http/Kernel.php
protected $routeMiddleware = [
    // ...
    'etag' => \App\Http\Middleware\ETagResponseMiddleware::class,
];

Aplicação na rota:

<?php
// routes/api.php
Route::get('users/{user}', function (App\Models\User $user) {
    return new App\Http\Resources\UserResource($user);
})->middleware('etag');

Resultado: cliente com If-None-Match igual ao ETag recebe 304. Sem cabeçalho, recebe 200 + ETag.

4) Exemplo 2: caso avancado do mundo real

4.1 Contexto: coleções paginadas e relações

Problema real: endpoint /posts retorna uma coleção paginada com autor, comentários count e filtros. Recalcular hash do corpo a cada requisição é custoso; entretanto, mudanças parciais (comentário novo) devem invalidar ETag.

Solução: gerar fingerprint da query baseada em:

  • max(updated_at) da coleção (maior timestamp entre posts retornados)
  • count total (paginacao)
  • versao de cache das relações (ex: comments_count token)

4.2 Implementação: Fingerprint service + Redis cache

Antes do código: encapsulamos a lógica em um serviço que recebe um Builder e retorna um token. O token pode ser armazenado em Redis com TTL curto para evitar recalcular constantemente.

<?php
namespace App\Services;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Redis;

class QueryFingerprint
{
    public function fingerprint(Builder $builder, string $cacheKey): string
    {
        // Use SQL + bindings como parte do fingerprint para consultas complexas
        $sql = $builder->toSql();
        $bindings = json_encode($builder->getBindings());

        // Pegue metadados úteis: maior updated_at e count
        $clone = clone $builder;
        $maxUpdated = $clone->getQuery()->newQuery()->fromSub($builder->toBase(), 't')->max('updated_at') ?: '';

        $count = $builder->toBase()->getCountForPagination();

        $raw = $sql . '|' . $bindings . '|' . $maxUpdated . '|' . $count;
        $key = 'fingerprint:' . sha1($raw);

        $cached = Redis::get($key);
        if ($cached) {
            return $cached;
        }

        $token = '"' . sha1($raw . ':' . now()->timestamp) . '"';
        // TTL de 30s para reduzir pressão de recalculo; ajustar conforme perfil
        Redis::setex($key, 30, $token);

        return $token;
    }
}

Depois do código: no controller, você injeta o serviço, gera fingerprint e responde condicionalmente.

<?php
// App/Http/Controllers/PostController.php
namespace App\Http\Controllers;

use App\Models\Post;
use App\Services\QueryFingerprint;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class PostController
{
    public function index(Request $request, QueryFingerprint $fingerprintService): JsonResponse
    {
        $query = Post::withCount('comments')->where('status', 'published');

        // Apply filters / sorts / pagination here
        $page = (int) $request->query('page', 1);
        $perPage = 20;
        $paginated = $query->orderBy('published_at', 'desc')->paginate($perPage, ['*'], 'page', $page);

        $token = $fingerprintService->fingerprint($query, 'posts_list_' . $page);

        $ifNoneMatch = $request->headers->get('if-none-match');
        if ($ifNoneMatch && trim($ifNoneMatch) === $token) {
            return response()->json([], 304)->header('ETag', $token);
        }

        $response = response()->json($paginated);
        $response->header('ETag', $token);
        $response->header('Cache-Control', 'private, max-age=0, must-revalidate');

        return $response;
    }
}

Explicação: o fingerprint combina SQL + bindings + metadados. Armazenamos token em Redis com TTL curto. Quando posts, comentários ou edições atualizam updated_at, o token muda no próximo recalculo e clientes recebem nova versão.

4.3 Integração com eventos e precompute

Para reduzir recalculações, atualize um token pré-computado no momento de escrita (listeners de modelo):

<?php
// App/Listeners/PostChanged.php
namespace App\Listeners;

use Illuminate\Support\Facades\Redis;
use App\Models\Post;

class PostChanged
{
    public function handle(Post $post)
    {
        // Exemplo simples: invalidate a chave global
        Redis::del('fingerprint:' . sha1('posts_list'));
        // Ou atualize um token específico
        Redis::set('posts_token', '"' . sha1($post->updated_at . ':' . time()) . '"');
    }
}

Resultado: operações de escrita mantêm tokens coerentes sem recalcular queries em leitura.

5) Debugging e troubleshooting

5.1 Testes e inspeção com ferramentas

Teste sempre com clientes reais:

  • curl -I para ver cabeçalhos apenas
  • curl -H "If-None-Match: \"abc\"" para simular condicionais
  • Laravel HTTP tests para asserts automatizados

Exemplo de teste Laravel (PHP) para assert 304:

<?php
public function test_posts_returns_304_when_etag_matches()
{
    $user = User::factory()->create();

    // Assume que o service já gerou token '"abc"' em Redis
    Redis::set('fingerprint:posts', '"abc"');

    $response = $this->withHeaders(['If-None-Match' => '"abc"'])->getJson('/api/posts');

    $response->assertStatus(304);
    $this->assertEquals('"abc"', $response->headers->get('ETag'));
}

Depois do código: esse teste valida comportamento de 304 e a presença do cabeçalho ETag na resposta vazia.

5.2 Erros comuns e armadilhas

Liste das armadilhas que aparece com frequência:

  • ETag calculado sobre corpo comprimido: se você calcula depois de gzip, proxies podem entregar versões diferentes; prefira calcular sobre representação canônica.
  • Vary não configurado quando resposta depende de Authorization ou Accept-Language — caches intermediários podem servir conteúdo inválido.
  • Usar hashing do corpo em endpoints com alta taxa de tráfego e corpo grande: CPU-bound.
  • Comparar ETags sem normalizar aspas/weak prefix (W/): falhas de matching causam 200 desnecessários.
  • Headers removidos por proxies/CDNs: alguns CDNs eliminam If-None-Match ou manipulam ETag. Teste no ambiente de produção.

Dica prática: para endpoints autenticados, prefira Cache-Control: private e evite caches intermediários que não lidam corretamente com Authorization.

Se o ETag for sensível a compressão, marque-o como fraco: prefixe com W/. Indique no Cache-Control se a resposta é privada.

6) Conclusao pratica

ETags são uma ferramenta pragmática para reduzir banda e latência em APIs Laravel quando corretamente aplicados. Use hashing direto para casos simples, mas prefira fingerprints baseados em metadados ou tokens pré-computados para coleções e endpoints de alta demanda.

Implemente por camadas: middleware simples para recursos singulares, serviços para coleções e listeners para manter tokens atualizados em operações de escrita. Combine com cabeçalhos Cache-Control e Vary apropriados.

Teste sempre com clientes reais e no ambiente de produção, porque proxies e CDNs costumam alterar comportamento de headers. Monitore CPU e latência ao introduzir hashing do corpo; se for problemático, migre para precompute ou metadados.

Checklist final:

  • Escolha a estratégia de fingerprint adequada (hash | metadata | precompute).
  • Implemente comparação correta de If-None-Match com normalização.
  • Adicione Cache-Control e Vary conforme necessidade.
  • Teste com curl e Laravel tests para garantir 304/200 corretos.

Implementando essas práticas você reduz tráfego desnecessário, melhora a experiência do cliente e mantém controle fino sobre a invalidação de cache sem introduzir complexidade desnecessária na camada de leitura.

Assuntos: PHP, Laravel, etag, http-cache, api

Leia também