# 04 — Cache, performance e entrega (L0–L6 + F1/F3/F2.1)

> Consolidação das camadas de performance do EPIC #23410. O foco é explicar
> **o que foi feito**, **por que**, **onde está no código** e **como validar**.
>
> Fontes: `correcao-cache-control-borda-l1.md`, `melhorias-cache-csrf-l1-l1.0.md`,
> `correcao-bootstrap-cache-l2.md`, `full-page-cache-l2-3.md`,
> `full-page-cache-origem-l2-3.md`, `query-cache-layout-l2-4.md`,
> `slow-queries-l3-4.md`, `build-producao-l4-1.md`, `vendorizacao-assets-l4-2.md`,
> `webp-middleware-l5.md`, `html-minify-l6-1.md`,
> `melhorias-pre-borda-f1-f3-f2.1.md`, `fase-final.md`.

---

## 1. Linha de base e metas

Baseline de 2026-09-08 (`2026-09-08-12-10`, 62 domínios):

| Métrica | Valor baseline | Problema |
|---|---:|---|
| `compression` | 0,34 | 34% das respostas sem gzip |
| `cache_policy` | 16/100 | quase nenhuma política de cache |
| `cache_hit` | 0,0 | nada servido em cache de borda |
| `origem` | ~1594 ms | 92% do TTFB |
| `transfer` | 6,89 MB | peso excessivo |

Objetivo: subir `compression` para 1,0, `cache_policy` ≥ 80, `cache_hit` > 90%
(estáticos) e reduzir `origem`/`ttfb`/`transfer`, sem regredir segurança
(`X-Hardening-Version`, HTTP/2, TLS 1.3, redirects=0, disponibilidade=100%).

---

## 2. L0 — Base de servidor

Detalhado em [`01-whm-cpanel.md`](01-whm-cpanel.md) e [`02-htaccess.md`](02-htaccess.md).
Resumo:

- Compressão: cPanel *Optimize Website* + `mod_brotli` opcional.
- OPcache: `ea-php72` system tunado via WHM.
- APCu: build manual via `pecl`; `composer.json` com `apcu-autoloader`.
- `.htaccess`: hardening + `immutable` + ETag + `X-Hardening-Version`.
- `php.ini`: template de conta e de sistema separados.

---

## 3. L1 — Cache de borda do HTML público (`CacheControl`)

### 3.1 Problema

Para uma borda de CDN cachear HTML, são necessárias **duas** condições:

1. `Cache-Control: public` (com `s-maxage` para a borda compartilhada);
2. **ausência de `Set-Cookie`** na resposta.

No Laravel, **todas** as rotas passam pelo grupo `web`
(`StartSession` + `VerifyCsrfToken`), que emite `laravel_session` e `XSRF-TOKEN`
em **toda** resposta — mesmo nas páginas públicas. Consequentemente:

- O grupo `web` injetava `Cache-Control: no-cache, private` (causa de
  `cache_policy=16`);
- O `Set-Cookie` impedia qualquer cache de borda (causa de `cache_hit=0`).

Um middleware que apenas sobrescrevesse o `Cache-Control` (`public`) **não
bastaria**: a borda continuaria vendo o cookie.

### 3.2 Abordagem adotada

Um único middleware (`App\Http\Middleware\CacheControl`) que, nas rotas GET do
portal público:

- remove o `Set-Cookie` (inclusive do array de cookies);
- remove `Pragma`/`Expires`;
- estabiliza o HTML removendo o **valor** do token CSRF;
- define `Cache-Control` público com `s-maxage`;
- define `Vary: Accept-Encoding` (sem `User-Agent`);
- emite ETag fraco e responde `304` quando `If-None-Match` bate.

### 3.3 Ordem no `Kernel` (cargo-crítica)

`app/Http/Kernel.php`:

```php
protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\ServerTiming::class,
        \App\Http\Middleware\PageCache::class,
        \App\Http\Middleware\CacheControl::class,
        \App\Http\Middleware\HtmlMinify::class,
        \App\Http\Middleware\PreloadCritical::class,
        \App\Http\Middleware\PictureTransform::class,
        \App\Http\Middleware\CspNonce::class,
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];
```

E em `$middlewarePriority`:

```php
protected $middlewarePriority = [
    \App\Http\Middleware\PageCache::class,
    \App\Http\Middleware\CacheControl::class,
    \Illuminate\Session\Middleware\StartSession::class,
    \Illuminate\View\Middleware\ShareErrorsFromSession::class,
    \Illuminate\Auth\Middleware\Authenticate::class,
    \Illuminate\Session\Middleware\AuthenticateSession::class,
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
    \Illuminate\Auth\Middleware\Authorize::class,
];
```

**Por quê?** O `Router::gatherRouteMiddleware()` faz `sortMiddleware()` pela
lista `$middlewarePriority`. Middleware **ausente** dessa lista vai para o fim da
ordem de entrada → roda **primeiro** no outbound, antes do `StartSession` emitir
cookie/`no-cache`. Colocando `CacheControl` **primeiro** no array e na
prioridade, ele roda **por último** no outbound e consegue remover/sobrescrever.

> O `ServerTiming` é o primeiro do grupo para envolver todo o pipeline e medir
> `app;dur=...`.

### 3.4 Código essencial do `CacheControl`

```php
public function handle(Request $request, Closure $next, $seconds = 300)
{
    $response = $next($request);

    if ($request->method() !== 'GET') return $response;
    if ($response->isRedirection() || $response->getStatusCode() >= 400) return $response;

    $route = $request->route();
    if ($route === null) return $response;

    // Só controllers do sub-namespace Portal\ (admin/api/prefeitura ficam fora)
    if (stripos($route->getActionName(), 'Portal\\') === false) return $response;

    $content = (string) $response->getContent();
    $content = $this->stripCsrfTokens($content); // L1.0 Fase 2
    $response->setContent($content);

    $response->headers->remove('Pragma');
    $response->headers->remove('Expires');
    $response->headers->remove('Set-Cookie');

    // Cookies ficam no array da resposta; remover o header não basta.
    foreach ($response->headers->getCookies() as $cookie) {
        $response->headers->removeCookie(
            $cookie->getName(), $cookie->getPath(), $cookie->getDomain()
        );
    }

    $response->headers->set(
        'Cache-Control',
        'public, max-age=' . $seconds
        . ', s-maxage=' . $seconds
        . ', stale-while-revalidate=86400, stale-if-error=604800'
    );

    // Vary: só Accept-Encoding
    $vary = array_map('trim', explode(',', $response->headers->get('Vary', '')));
    $vary = array_filter($vary, function ($v) {
        return $v !== '' && strtolower($v) !== 'user-agent';
    });
    if (!in_array('Accept-Encoding', $vary, true)) {
        $vary[] = 'Accept-Encoding';
    }
    $response->headers->set('Vary', implode(', ', $vary));

    $hash = md5($content);
    $etag = 'W/"' . $hash . '"';           // ETag FRACO
    $response->headers->set('ETag', $etag);

    $ifNoneMatch = (string) $request->headers->get('If-None-Match', '');
    if ($ifNoneMatch !== '') {
        foreach (array_map('trim', explode(',', $ifNoneMatch)) as $candidate) {
            $candidate = ltrim($candidate, 'W/');
            $candidate = trim($candidate, '"');
            $candidate = preg_replace('/-(gzip|br)$/i', '', $candidate);
            if ($candidate === $hash || $candidate === '*') {
                $response->setStatusCode(304);
                $response->setContent(null);
                break;
            }
        }
    }

    return $response;
}
```

### 3.5 Valores e semântica

| Header | Valor | Efeito |
|---|---|---|
| `Cache-Control` | `public, max-age=300, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800` | Borda e navegador podem cachear; `s-maxage` é o que a CDN honra. |
| `Vary` | `Accept-Encoding` | Varia só por compressão; não fragmenta por UA. |
| `ETag` | `W/"<md5>"` | Validador fraco, imune ao sufixo `-gzip`/`-br`. |
| `304` | corpo vazio | Revalidação condicional. |
| `Set-Cookie` | removido | Permite cache compartilhado. |
| `Pragma`/`Expires` | removidos | Evita `no-cache` legado. |

### 3.6 O bug do ETag forte + Apache

Em produção, `revalidation=0` mesmo com `Cache-Control: public`. Causa: o Apache
(`mod_deflate`/`mod_brotli`) anexa `-gzip`/`-br` a **ETags fortes** ao comprimir.
Clientes que pedem compressão recebiam `ETag: "<hash>-gzip"` e devolviam esse
valor em `If-None-Match`; o middleware comparava contra `<hash>` cru → `200`
sempre. O `artisan serve` do canário não gzipa, por isso os testes passavam.

**Correção:** emitir **ETag fraco** `W/"<md5>"` (o Apache não altera validadores
fracos; são encoding-insensitive por RFC 7232) **e** normalizar o
`If-None-Match` (remover `W/`, aspas e sufixo `-gzip`/`-br`). Agora o `304`
dispara para qualquer cliente.

### 3.7 L1.0 — CSRF desacoplado do HTML cacheável

#### O problema

Todo layout do portal embute:

```html
<meta name="csrf-token" content="{{ csrf_token() }}">
```

e hidden `_token` (ex.: `resources/views/portal/layouts/modelo1/layout/head.blade.php:15`,
iguais em `modelo2`–`modelo6`). Como o `CacheControl` remove o cookie de sessão,
cada GET anônimo abre nova sessão → o HTML varia por request:

1. `ETag = md5(corpo)` varia → `304` nunca dispara.
2. Se a borda cacheasse esse HTML `public`, serviria o token de uma sessão a outra
   → **CSRF 419** no submit.

#### Fase 1 — endpoint + JS

- `app/Http/Controllers/Portal/CsrfTokenController.php` + rota `GET /csrf-token`
  (nome `csrf.token`, grupo `web`): devolve `{token: csrf_token()}`,
  `Cache-Control: no-cache, private`, mantém o cookie de sessão e grava o cookie
  `portal_csrf` (httpOnly=false, legível por JS) para as próximas navegações.
- Excluída do cache (`excludeNames = ['csrf']`).

#### Fase 2 — strip + flip public

- `CacheControl` remove o **valor** do token do corpo:
  - `meta[name=csrf-token]`;
  - `input[name=_token]`;
  - `var token = '...'`;
  - `_token: '...'`;
  - `'X-CSRF-TOKEN': '...'`.
- Regex ancorado em `[A-Za-z0-9+]{40}`; não toca em hashes de asset/URL.
- O nome do atributo permanece, o valor é esvaziado → corpo estável, sem token
  vazado entre sessões.
- `excludeNames` reduzido a `['csrf']`.
- `resources/views/portal/layouts/csrf-inject.blade.php` centraliza o JS:
  `window.PortalCsrf` obtém o token (cookie `portal_csrf` ou `/csrf-token`),
  injeta `X-CSRF-TOKEN` em `jQuery.ajaxSend` e `window.fetch`, e preenche `_token`
  no `submit` de `form[POST]`. Incluído em `modelo1`–`modelo6/layout/head.blade.php`.
- **CMS intocado:** `CacheControl` só atua em `Portal\`; admin/API/prefeitura
  mantêm sessão/cookie. Os ~60 blades do portal não foram editados.

### 3.8 Validação do L1

```bash
# Página pública: deve vir pública, sem cookie, com Vary
curl -s -D - -o /dev/null https://SEU_HOST/ \
  | grep -iE 'cache-control|set-cookie|vary|x-page-cache|etag'
# esperado:
#   Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800
#   Vary: Accept-Encoding
#   (NENHUM Set-Cookie)

# Admin: deve continuar no-cache, private + Set-Cookie
curl -s -D - -o /dev/null https://SEU_HOST/cms \
  | grep -iE 'cache-control|set-cookie'

# Formulário: mantém cookie/sessão para CSRF (a página pode ser pública, mas o
# token é injetado client-side; validar submit sem 419)
curl -s -D - -o /dev/null https://SEU_HOST/contato | grep -i set-cookie

# 304 (GET real, com ETag)
ETAG=$(curl -sI https://SEU_HOST/ | awk -F': ' 'tolower($1)=="etag"{print $2}' | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" https://SEU_HOST/
# esperado: 304
```

> **Gotcha:** `curl -sI` manda HEAD e o middleware só age em GET. Use GET real.
> Em Docker/prod com OPcache `validate_timestamps=0`, o bind-mount nem sempre
> atualiza o mtime; reinicie o php-fpm/container antes de validar.

---

## 4. L2.1 — Caches de bootstrap do Laravel

### 4.1 Problema

O `deploy.sh` não rodava `config:cache`/`route:cache`/`optimize`, então a cada
request o Laravel re-parseava todos os `config/*.php`, re-resolvia todas as rotas
e re-bootstrapava o container.

### 4.2 Pré-requisitos que bloqueavam

#### `route:cache` falhava (Closures como ação de rota)

`php artisan route:cache` serializa todas as rotas e falha se qualquer rota usar
Closure como ação (2º argumento) ou como 3º argumento “after”. Em
`routes/basepro.php` havia 6 rotas bloqueadoras:

| Rota | Closure | Correção |
|---|---|---|
| `logout` | 3º arg `function(){ abort(404); }` (sobrescrevia o logout) | removido o 3º arg |
| `activate` | 3º arg vazio | removido, mantido `->name('activate')` |
| `cms /` | ação Closure `return redirect()->route('dashboard.index')` | `Route::redirect('/', '/cms/dashboard')` |
| `acidade.getByState` | 3º arg vazio | removido, mantido o nome |
| `usuario.addRoles` (x2) | 3º arg vazio | removido, mantido o nome |

> Grupos `Route::group(..., function(){})` **não** bloqueiam `route:cache`; só
> ação/after Closure.

#### `config:cache` falhava (`env()` fora de `config/`)

`config:cache` faz `env()` retornar `null` em runtime (exceto dentro de
`config/*`). Havia `env()` em código de app:

- `app/Http/Controllers/Portal/InformationController.php`
  (`GEMINI_API_URL/MODEL/KEY`);
- `app/Http/Controllers/Admin/ActivationController.php` (`APP_URL`).

Correção:

- novo `config/gemini.php` expõe `gemini.api_url/api_model/api_key` (lê `env()`
  no build do cache);
- `InformationController` → `config('gemini.*')`;
- `ActivationController` → `config('app.url')`.

Após isso, **nenhum** `env()` resta fora de `config/`/`vendor/` →
`config:cache` seguro.

### 4.3 Cadeia de produção (`composer prod`)

```json
"prod": [
  "composer dump-autoload --optimize --classmap-authoritative --apcu --no-dev",
  "php artisan images:redigest-webp --limit=100000",
  "php artisan cache:clear",
  "php artisan config:clear",
  "php artisan view:clear",
  "php artisan route:clear",
  "php artisan clear-compiled",
  "php artisan migrate:status",
  "php artisan migrate --force",
  "php artisan config:cache",
  "php artisan route:cache",
  "echo 'Reinicie o PHP-FPM no WHM para aplicar as atualizações'"
]
```

> **Atenção:** essa cadeia **não** executa reset de OPcache. O `deploy.sh` chama
> `composer prod` e termina. O reset precisa ser feito à parte (ver
> [`06-deploy-operacao.md`](06-deploy-operacao.md)). O `echo` final é apenas um
> lembrete manual.

### 4.4 Verificação

```bash
php artisan route:cache      # gera bootstrap/cache/routes.php sem erro de Closure
php artisan config:cache     # gera bootstrap/cache/config.php
php artisan route:list | grep -E 'logout|activate|cms|acidade.getByState|usuario.addRoles'
```

---

## 5. L2.3 — Full-Page Cache (FPC) de origem

### 5.1 Objetivo

Servir o HTML já renderizado diretamente do store de Cache (file/redis) em hits
subsequentes — o Laravel **não re-renderiza** (controllers, ServiceProviders de
layout, views e queries não rodam). Reduz `origem`/`TTFB`.

### 5.2 Arquitetura

`app/Http/Middleware/PageCache.php`, registrado como **1º do grupo `web`** (e na
prioridade), portanto roda antes de tudo (inclusive `StartSession`) e pode
short-circuitar em HIT.

**Guards (só cacheia):**

- `method() === 'GET'`;
- controller no sub-namespace `Portal\`;
- rota não excluída (apenas `csrf`);
- usuário **não** autenticado (`auth()->check()`);
- resposta `200` + `Content-Type: text/html` + **sem** `Set-Cookie`.

**Em HIT:**

```php
$body = Cache::get($key);
$cached = new Response($body, 200, ['Content-Type' => 'text/html; charset=UTF-8']);
$cached->headers->set('X-Page-Cache', 'HIT');

CspNonce::applyToCachedResponse($cached, $request);

return (new CacheControl())->handle($request, function () use ($cached) {
    return $cached;
});
```

O HTML cached já está sem o token CSRF (estabilizado no MISS), então o
`CacheControl` reaplicado produz o mesmo `ETag` → `304` funciona **com CSP
desligado** (estado atual). Com CSP ligado, o nonce é injetado antes do ETag
(ordem do `Kernel`) e o corpo gravado no MISS já contém o nonce; o HIT não o
substitui. Ver [`05-seguranca.md`](05-seguranca.md) §3.2.1 e
[`10-achados-e-patches.md`](10-achados-e-patches.md) F-01.

**Em MISS:** deixa `$next` rodar; se `shouldCache()` for verdadeiro, grava
`Cache::put($key, $body, $ttl)` e marca `X-Page-Cache: MISS`.

**Chave:** `page_cache:v1:` + `md5(normalizedUrl . '|' . locale)`.

### 5.3 Normalização de URL

`PageCache::normalizedUrl()` remove parâmetros de tracking (`utm_*`, `gclid`,
`fbclid`, `msclkid`, `_ga`, etc. — lista em
`config/cache.php → page_cache.ignored_query_params`) e ordena a query.

Exemplos:

- `/?utm_source=facebook&gclid=abc` → mesma chave de `/` (HIT);
- `/?page=2` → chave distinta (MISS).

### 5.4 Invalidação

- **TTL** (`config('cache.page_cache.ttl', 300)`) — teto de obsolescência.
- **Eventos de modelo** (`AppServiceProvider`): `created`/`deleted` dos models em
  `cache.page_cache.invalidate_models` disparam `PageCache::flush()`;
  `saved` dos models de layout (menu/categorias/config) também.
- `Content` dispara flush só quando a edição atinge categoria de layout
  (`PageCache::LAYOUT_CONTENT_CATEGORIES`) — views (`increment('clicks')`) não
  geram thrash.
- **Comando:** `php artisan cache:page:clear`.
- **L2.4:** `PageCache::flushLayout()` invalida também os caches de dados de
  layout.

### 5.5 Configuração

`config/cache.php`:

```php
'page_cache' => [
    'enabled' => env('PAGE_CACHE_ENABLED', true),
    'ttl' => env('PAGE_CACHE_TTL', 300),
    'ignored_query_params' => [ /* utm_*, gclid, ... */ ],
    'invalidate_models' => [
        \App\Prefecture\Content::class,
        \App\Prefecture\CategoryContent::class,
        \App\Prefecture\StaticPage::class,
        \App\Prefecture\Survey::class,
        \App\Prefecture\CalendarEvent::class,
    ],
],
```

`.env`:

```dotenv
PAGE_CACHE_ENABLED=true
PAGE_CACHE_TTL=300
```

### 5.6 Validação

```bash
# 1ª requisição (warm-up) -> MISS; 2ª -> HIT
curl -s -D - -o /dev/null https://SEU_HOST/ | grep -i x-page-cache
curl -s -D - -o /dev/null https://SEU_HOST/ | grep -i x-page-cache

# rota não-home também deve funcionar
curl -s -D - -o /dev/null https://SEU_HOST/agenda | grep -i x-page-cache
curl -s -D - -o /dev/null https://SEU_HOST/agenda | grep -i x-page-cache

# HIT deve manter Cache-Control/ETag e responder 304
curl -s -D - -o /dev/null https://SEU_HOST/ | grep -iE 'cache-control|etag|x-page-cache'

# purga manual
php artisan cache:page:clear
# próxima requisição volta a MISS
```

### 5.7 Riscos

- **Namespace `Portal\` apenas:** rotas públicas fora dele (ex.:
  `Prefecture\NewsLetterController`, preview de diário) **não** são cacheadas.
- **Autenticado:** páginas com sessão logada não são cacheadas (defesa).
- **Obsolescência:** até `ttl` (300s) o conteúdo pode aparecer “velho”; a
  invalidação por evento corta isso na edição via CMS.
- `MAX_KEYS = 2000` (registry em memória do cache) — acima disso, desliza as mais
  antigas.

---

## 6. L2.4 — Cache de layout e queries (N+1)

### 6.1 Problema

Os ServiceProviders de layout registram view composers que rodam em **toda**
requisição de portal e reexecutam dezenas de queries de dados quase-estáticos,
mesmo quando o FPC serviria o HTML.

| Provider | Queries/request (aprox.) | Dados |
|---|---|---|
| `MenuServiceProvider` | menu header (com `subItens`) + menu rápido + atalhos + `contact_link` | menu/categorias |
| `FooterServiceProvider` | config rodapé (~17 keys) + 2 menus + redes sociais + `UpdateLogs` | footer/config |
| `ConfigHeaderServiceProvider` | `Indicator` + ~28 keys de `Configuration` + `Supplier` + acessos rápidos + highlight | header/config |
| `BannerServiceProvider` | banners + super banners | banners |
| `NewsMorePopularServiceProvider` | top-3 notícias populares | sidebar |
| `ModulosServiceProvider` | já `Cache::rememberForever('modulos')` | módulos |

~15–20 queries de dados estáveis eram refeitas em todo request (inclusive admin).

### 6.2 Solução

Cada dataset de layout foi envolvido em `Cache::remember('<prefix>', $ttl, fn)`
com `$ttl = config('cache.page_cache.ttl', 300)`:

- `MenuServiceProvider`: `layout.menu_header`, `layout.menu_rapido`,
  `layout.menu_atalhos`, `layout.contact_link`;
- `FooterServiceProvider`: `layout.footer_config`, `layout.footer_menus`,
  `layout.footer_redes`, `layout.system_version`;
- `ConfigHeaderServiceProvider`: `layout.header_bulletins`,
  `layout.header_config`, `layout.header_fornecedor`, `layout.header_acessos`,
  `layout.header_highlight`;
- `BannerServiceProvider`: `layout.banners`, `layout.super_banners`;
- `NewsMorePopularServiceProvider`: `layout.news_more_popular`.

Os resultados (Collections/Models com relações eager-loaded) são serializados no
store de Cache (file). Ao reidratar, as relações permanecem carregadas.

### 6.3 Invalidação

`PageCache::flush()` (disparado por `created`/`deleted` dos models de conteúdo)
chama `PageCache::flushLayout()`, que faz `Cache::forget` de todas as chaves
`layout.*`. `PageCache::LAYOUT_KEYS` é a lista canônica.

Reforço em `AppServiceProvider::boot()`:

- `saved` + `deleted` em `MenuItem`, `Configuration`, `Highlight`, `Indicator`,
  `Supplier`, `UpdateLogs` → `PageCache::flush()`;
- `Content::saved` **somente** quando a categoria está em
  `PageCache::LAYOUT_CONTENT_CATEGORIES` (`banners`, `super-banners`,
  `rede-social`, `acesso-rapido`, `destaques`).

Assim editar banner/rede/acesso limpa na hora; o `increment('clicks')` de
notícias/páginas não dispara purga.

### 6.4 Eager loading

Os providers já usavam `with(...)`; as controllers de portal
(`PortalController`, `NewsController`, `PublicationController`) também. O gargalo
real era a **repetição** das queries de layout, resolvido pelo cache. Ainda
assim, `categoryContents` foi eager-loaded em:

- `GetInformationsPrefectureTrait::getContentByCategoryWith`;
- `NewsTrait::getContentsByParentCategory` (2 queries);
- `$sessions` em `PortalController.php:144`.

### 6.5 Validação

- `tests/Unit/LayoutCacheTest.php` (4 testes de invalidação).
- `tests/Unit/PageCacheTest.php::flush_invalida_tambem_os_caches_de_layout_l2_4`.
- `tests/Unit/CountingCacheRepository.php` (fake contador de closures).
- Medir `origem`/`ttfb` e nº de queries/request no canário (baseline × depois).

---

## 7. L3.4 — Queries lentas, N+1 e índices

### 7.1 Diagnóstico

As tabelas de conteúdo **não tinham nenhum índice secundário** — apenas PK. Todo
`where('slug')`, `where('category_contents_id')`, `where('status')`,
`where('publication_date')`, `where('key')` era *sequential scan*.

Consultas repetidas pesadas:

- `getTemplateActived()` (`GetInformationsPrefectureTrait.php:92`) fazia
  `Configuration::where('key','layout')->first()` **e** um `UPDATE` de side-effect
  (`pagina_em_manutencao`), invocado ~6× por request;
- `CategoryContent::where('slug', X)->first()` repetido
  (`PortalController.php:165,168,200`);
- `whereHas('categoryContents', …)` aninhados em
  `slidesNews`/`directContents`/`allTransparencyContents`;
- `DB::select` cru com interpolação de `$category->id`
  (`PortalController.php:349`);
- `categoryContents()` é `belongsTo` (`Content.php:107`) → N+1 em views.

### 7.2 Fase 1 — Índices (migration)

`database/migrations/2026_09_10_120000_add_performance_indexes_l34.php`
(cópia em [`arquivos/migration-performance-indexes-l34.php`](arquivos/migration-performance-indexes-l34.php)):

| Tabela | Índices |
|---|---|
| `contents` | `slug`; `(category_contents_id, status, publication_date)`; `(highlights, publication_date)`; `(carousel, category_contents_id)`; `deleted_at`; `order` |
| `category_contents` | `slug`; `(category_contents_id, status, order)` |
| `archives` | `contents_id`; `static_pages_id` |
| `configurations` | `key` |
| `calendar_events` | `status` |
| `static_pages` | `slug` |
| `highlights` | `(status, layout, position)` |
| `activity_log` | `(description, created_at)` |

Todos **non-unique** para não quebrar dados legados com `slug`/`key` duplicados.
A migration define **15 índices** (6 em `contents`, 2 em `category_contents`, 2 em
`archives`, 1 em cada uma das demais tabelas). O documento original cita “16
índices criados”; a diferença provável é o índice legado já existente em
`activity_log`, que não é criado por esta migration. O teste automatizado afirma
os 15 índices esperados.

> **Deploy em produção:** em tabela grande, `Schema::table()->index()` faz
> `CREATE INDEX` bloqueante. Fora de horário crítico, usar
> `CREATE INDEX CONCURRENTLY` (pgsql) ou janela de manutenção. O app suporta
> pgsql/mysql; as definições usam `->index()` portátil.

### 7.3 Fase 2 — Verificação do planner

`tests/Unit/PerformanceIndexTest.php`:

1. `testIndexesExist` — afirma que os 15 índices existem (`pg_indexes`);
2. `testHotQueriesUseIndexes` — 14 queries quentes com
   `SET enable_seqscan = off` + `EXPLAIN`, afirmando **Index Scan** e nunca
   **Seq Scan**.

Resultado documentado: **15 testes, 43 asserções — OK**.

### 7.4 Fase 3 — Correções de código

- **Memoização de `getTemplateActived()`** (cache estático por request da leitura
  de `Configuration`; o `UPDATE`/`abort` de `pagina_em_manutencao` é preservado).
- **Eager loading `categoryContents`** em `getContentByCategoryWith`,
  `getContentsByParentCategory` e `$sessions`.
- **`DB::select` cru → Eloquent** (`PortalController.php:349`):

```php
Content::where('category_contents_id', $id)
    ->whereNull('deleted_at')
    ->selectRaw('extract(year from publication_date) as ano')
    ->groupBy('ano')
    ->orderByDesc('ano')
    ->get();
```

Sem interpolação de string (bindings) e usando o novo índice.

### 7.5 Validação

```bash
docker exec public_infra_update-app-1 php vendor/bin/phpunit
# PerformanceIndexTest 15/15; EagerLoadMemoizeTest 2/2; suíte 55 testes/327 asserções
```

---

## 8. L4.1 — Build de produção de assets

### 8.1 Problema encontrado

- `public/assets/css/app.css` estava com **0 bytes**: o `deploy.sh` nunca rodava
  build e o `webpack.mix.js` compilava para `public/css/app.css` (caminho não
  referenciado; as views usam `public/assets/**`). O admin perdia o estilo
  principal (Bootstrap + icheck).
- `public/mix-manifest.json` era um “frankenstein”: mesclava campos de PWA
  (`short_name`, `name`, `description`) com entradas obsoletas do Mix.
- **Toolchain incompatível:** `laravel-mix@1.7` / `webpack@3` / `node-sass@4.14`
  não compilam em Node 26 (erro nativo do `node-sass`). Decisão: **build em
  CI/local + commit do resultado**; **não** rodar `npm` no `deploy.sh`.

### 8.2 O que foi feito

1. **Manifest do Mix desacoplado do PWA**
   - `git mv public/mix-manifest.json public/manifest.webmanifest` (preserva PWA);
   - 8 layouts atualizados para `<link rel="manifest" href="{{ url('/manifest.webmanifest') }}">`;
   - `public/mix-manifest.json` agora é só do Mix:
     `{ "/assets/css/app.css": "/assets/css/app.css?id=<hash>" }`.
2. **CSS do admin compilado e versionado**
   - `resources/assets/sass/app.scss` (Bootstrap + icheck) →
     `public/assets/css/app.css` (**minificado, 176 KB**);
   - `webpack.mix.js` aponta para `public/assets/css`:
     ```js
     let mix = require('laravel-mix');
     mix.sass('resources/assets/sass/app.scss', 'public/assets/css').version();
     ```
   - `admin/layouts/app.blade.php:46` usa
     `{{ HTML::style(mix('assets/css/app.css')) }}` →
     `/assets/css/app.css?id=<hash>`.
   - `icheck` inline no `app.css`; sprites PNG em
     `public/assets/css/icheck/*.png`, `url()` reescritos.
3. **Validação**
   - `app.css` > 0 e minificado (176326 bytes, 4 quebras de linha);
   - `mix('assets/css/app.css')` resolve para `/assets/css/app.css?id=22aa071c1d`;
   - PWA em `/manifest.webmanifest`; nenhuma referência a `/mix-manifest.json`.

### 8.3 Reproducibilidade

- **CI com Node compatível (recomendado):** `npm ci && npm run production` com
  `node-sass`/`webpack` funcionais regenera `public/assets/css/app.css` e
  `public/mix-manifest.json`.
- **Fallback Dart Sass:** `sass` (puro-JS) compilando `resources/assets/sass/app.scss`
  com `loadPaths=[node_modules, resources/assets/sass]`, inlining do `icheck` e
  cópia das PNG. O hash no `mix-manifest.json` deve ser recalculado
  (`md5sum public/assets/css/app.css`) se o CSS mudar.

---

## 9. L4.2 — Vendorização de assets externos

### 9.1 Problema

~9 domínios externos serviam sub-recursos de bloco: `cdnjs.cloudflare.com`,
`fonts.googleapis.com`, `maxcdn.bootstrapcdn.com`, `ajax.googleapis.com`,
`pro.fontawesome.com`. Cada request cross-origin adicionava handshake
(DNS/TCP/TLS) e dependia de terceiros.

O sniffCSS no canário confirmou que a home renderiza `font-family: Rubik` e `MR`;
as Google Fonts eram realmente aplicadas (não era CSS morto).

### 9.2 O que foi vendorizado

Para `public/assets/vendor/`:

- Google Fonts (Rubik, Nunito Sans, Source Sans Pro, Encode Sans Expanded, Lato)
  → `fonts/google/` com `fonts.css` + **73 `woff2`**;
- `popper.min.js`;
- Font Awesome 5.15.4 (`fontawesome/css/all.min.css` + `webfonts/`);
- Sortable 1.15.0;
- angular-material 1.0.0;
- Material Icons (1 `woff2`);
- Bootstrap 3.3.6 + glyphicons (admin forms);
- select2 4.0.3;
- Chart.js 2.7.0.

Todas as views trocadas de CDN → `{{ asset('assets/vendor/...') }}`.

### 9.3 Outras melhorias

- **`preconnect`** para 3rd-parties remanescentes (`gstatic`, `google`,
  `vlibras`) no partial `portal/layouts/partials/preconnect.blade.php`,
  incluído em `default/master` e nos 6 `modelo*/head`.
- **`defer`** no widget VLibras:
  `<script defer src="https://vlibras.gov.br/app/vlibras-plugin.js">`.
- **Correção do reCAPTCHA:** `reCAPTCHA_site_key` literal →
  `{{ config('recaptcha.public_key') }}` em `contact.blade.php` e
  `ouvidoria/contact.blade.php`.
- **Rodada 2:** `preconnect` para YouTube/AddToAny/Facebook/queimadas;
  `Portuguese-Brasil.json` do DataTables vendorizado; `defer` em 14 scripts do
  `modelo6`; CSS não-crítico não-bloqueante
  (`media="print" onload="this.media='all'"`); `loading="lazy"` nos 6 iframes
  YouTube.
- **Externos mantidos:** `gstatic charts/loader.js` (init inline síncrono),
  `vlibras.gov.br`, `google.com/recaptcha`, `pro.fontawesome.com` (FA-Pro
  licenciado), `swagger`/`welcome` (dev-only).

### 9.4 Reproducibilidade e cache

- Baixar dos CDNs oficiais e reescrever `url()` para caminhos relativos sob
  `assets/vendor/<lib>/`.
- **Bust de cache:** a regra L0.3 é `immutable` e os arquivos não têm hash no
  nome. Ao trocar uma lib, **namespacear por versão** (ex.:
  `assets/vendor/select2/4.0.3/`) — já feito para `bootstrap-3.3.6/` e
  `chart.js` (versão no filename). O `select2/` atual carece de versão no path.

### 9.5 Validação

```bash
# 0 refs vendorizáveis
grep -rnE 'cdnjs|fonts.googleapis|maxcdn|ajax.googleapis' resources/views \
  --include=*.blade.php | grep -v '{{' | grep -v 'fontawesome' || true

# 0 placeholder literal
grep -rn 'reCAPTCHA_site_key' resources/views || true

# assets com immutable + gzip
curl -sI https://SEU_HOST/assets/vendor/fonts/google/fonts.css \
  | grep -iE 'cache-control|content-encoding'
```

---

## 10. L5 — WebP responsivo via middleware

### 10.1 Como funciona

1. **Upload (futuro):** todo upload passa por `FileTrait::compress()`, que chama
   `generateWebpVariants()` → gera
   `variants/{base}-{360,720,1200,1920}w.webp` (+ `original` para <360px) e o
   sidecar `variants/{base}.variants.json` (mapa largura → caminho relativo).
   Idempotente.
2. **Background:** `php artisan images:redigest-webp --limit=300` (agendado
   `everyTenMinutes` em `app/Console/Kernel.php`) digere o acervo em chunks e
   “dá a volta” (offset em `.webp_redigest_offset`).
3. **Render (global):** `PictureTransform` (grupo `web`, após `CacheControl`)
   reescreve `<img>` de imagem local em `<picture>` com
   `<source type="image/webp">` + `<img>` fallback. Se a imagem não tem sidecar,
   mantém o `<img>` original — o background otimiza na próxima corrida.

### 10.2 Detalhes de correção

- Engine: Intervention com driver **Imagick** (PHP 7.2). Quality 82.
- `stripImage()` para reduzir tamanho.
- Pula `svg` (vetorial) e GIF animado (preserva animação).
- **MIME:** sem `AddType image/webp` o Apache servia `.webp` sem `Content-Type` →
  navegador (nosniff) recusava. Corrigido no `.htaccess` `[L5]`.
- **Anti double-download:** `<img>` fallback com `loading="lazy"`; primeiro
  `<source>` WebP com `fetchpriority="high"` quando a imagem era `eager`/LCP.

### 10.3 Preload do LCP

Para a 1ª `<img>` `eager`/`fetchpriority="high"` (candidata a LCP), o middleware
injecta no `<head>` um `<link rel="preload" as="image" type="image/webp"
fetchpriority="high">` **por variante**, cada um com o **mesmo `media`** da
`<source>` correspondente:

- variantes > menor largura → `media="(min-width: Wpx)"`;
- menor variante (source default, sem `media`) →
  `media="(max-width: {proxLargura-1}px)"`.

Assim, em qualquer viewport apenas uma variante é pré-carregada. Imagens `lazy`
não recebem preload. AVIF não é usado (Imagick deste build não suporta; tier é
WebP → JPEG original).

### 10.4 Validação

```bash
# home do canário: <picture> + <source type="image/webp">
curl -s https://SEU_HOST/ | grep -o '<picture>' | wc -l

# variante servida como image/webp
curl -sI https://SEU_HOST/storage/.../variants/imagem-720w.webp \
  | grep -i content-type
# esperado: image/webp

# command idempotente
php artisan images:redigest-webp --limit=100
```

---

## 11. L6.1 — Minificação de HTML + L6.3 preload

### 11.1 `HtmlMinify`

`app/Http/Middleware/HtmlMinify.php`:

- só GET, `!app.debug`, `Content-Type: text/html`;
- preserva **verbatim** blocos `<pre>`, `<code>`, `<textarea>`, `<script>`,
  `<style>`, `<svg>` via placeholders;
- colapsa whitespace entre tags;
- não remove comentários HTML `<!-- -->` (decisão de zero risco);
- **não** usa regex para comentários JS; a conversão `//` → `/* */` é feita em
  nível de fonte (85 blades, 313 comentários).

Ordem no grupo `web`: após `CacheControl`, antes de `PictureTransform`.
O outbound roda em ordem inversa: `PictureTransform` gera `<picture>`, depois
`HtmlMinify` minifica, por fim `CacheControl`/`PageCache` cacheiam o HTML final.

### 11.2 `PreloadCritical` (L6.3)

Injeta `<link rel="preload" as="style">` para:

- `assets/css/app.css`;
- `assets/vendor/fonts/google/fonts.css`.

Guardas: GET, `!app.debug`, `text/html`, controller `Portal\`, idempotente (não
re-injeta se já existe). O preload de imagem LCP é feito pelo `PictureTransform`.

### 11.3 `ServerTiming` e RUM

- `ServerTiming`: mede `microtime` do handle e seta `Server-Timing: app;dur=...`.
  Bug corrigido: `headers` é **propriedade**, não método; usar
  `isset($response->headers)`.
- `public/assets/js/custom/portal/rum.js`: `PerformanceObserver` para LCP, CLS,
  INP; `navigator.sendBeacon` para `window.__rumEndpoint` (ou `console.debug`).
- `lighthouse/budget.json`: asserts `LCP≤2500`, `CLS≤0.1`, `TBT` warn ≤300.
- **Speculation Rules** no partial `preconnect.blade.php`.

### 11.4 Validação

```bash
# HTML minificado (sem quebrar script)
curl -s https://SEU_HOST/ | grep -c '<script'   # deve ser > 0
# <script> preservado com // e https:// intactos
curl -s https://SEU_HOST/ | grep -o 'https://[^"]*' | head

# header de timing
curl -sI https://SEU_HOST/ | grep -i server-timing
```

---

## 12. F1/F3/F2.1 — Correções pré-borda

### 12.1 F1.1 — `sw.js` não pode ser `immutable`

O bloco `[L0.3b]` vinha **antes** de `[L0.3]`; como ambos usam `Header set` para o
mesmo header, o **último vence** — o `sw.js` herdava `immutable`. Correção: mover
`[L0.3b]` para **depois** de `[L0.3]`.

### 12.2 F1.2 — remover `User-Agent` do `Vary`

O app já remove no HTML, mas o header voltava em estáticos e respostas do
Apache/WHM. Foram adicionadas três diretivas `Header edit Vary` **sem
`<IfModule>`** (cruas), cobrindo `Accept-Encoding, User-Agent`, a ordem inversa e
`User-Agent` isolado. Se o valor não casar/header ausente, é **no-op**.

> **Lacuna:** não é possível alterar a camada WHM/vhost daqui. Só produção
> confirma se o `Header edit` do `.htaccess` vence o include global.

### 12.3 F3.2 — `robots.txt` 200 e 404 real

- `Portal\RobotsController@show` → `text/plain` com
  `Sitemap: {url('/sitemap.xml')}` absoluto do host.
- `Handler::render` para `NotFoundHttpException` devolve `admin.errors.404` com
  **status 404 direto** (antes: `302 → /error/404`, que respondia 200).
- `ErrorsController@notFound` e `PortalController@staticShow` também devolvem 404.

### 12.4 F2.1 — chave de FPC normalizada + `CdnPurgeService`

- `PageCache::normalizedUrl()` remove tracking e ordena query.
- `CdnPurgeService` (`config/cdn.php`) com drivers `cloudflare`/`fastly`/`webhook`,
  **no-op por default** enquanto não há provedor, **nunca lança** (falha de CDN é
  logada e absorvida). Acionado em `PageCache::flush()`.

`config/cdn.php`:

```php
'enabled' => env('CDN_PURGE_ENABLED', false),
'timeout' => (int) env('CDN_PURGE_TIMEOUT', 2),
'connect_timeout' => (int) env('CDN_PURGE_CONNECT_TIMEOUT', 1),
'providers' => [
    // [
    //     'name' => 'cloudflare-primary',
    //     'driver' => 'cloudflare',
    //     'enabled' => true,
    //     'zone' => env('CDN_CLOUDFLARE_ZONE'),
    //     'token' => env('CDN_CLOUDFLARE_TOKEN'),
    // ],
],
```

### 12.5 F3.1 — cache versionado da listagem de notícias

`NewsTrait::getContentNews()` usa `Cache::remember` com chave ancorada em
`PageCache::version()`. `PageCache::flush()` **bumpa** a versão; publicar/remover
conteúdo invalida as listas imediatamente.

### 12.6 Sitemap — atenção

O snapshot contém `RewriteRule ^sitemap\.xml$ index.php [L]` **antes** do bloco
`-f` e rota `Route::get('/sitemap.xml', 'Portal\SitemapController@sitemap')`.
Ainda existe `public/sitemap.xml` legado no repositório. Validar:

```bash
curl -s -D - https://SEU_HOST/sitemap.xml | head -20
# esperado: 200, Content-Type: application/xml, <loc> do host atual,
#           sem URLs internas/legadas
```

Se o estático for servido, revisar a ordem do `.htaccess` e remover o arquivo
legado (com aval).

---

## 13. Fase final — estrutura dos templates

- **Raleway vendorizada:** `woff2` latin (v37 variável) em
  `public/assets/vendor/fonts/google/woff2/raleway-latin.woff2` + `@font-face`
  em `fonts.css`; removido `<link>` do Google dos 7 heads.
- **`Server-Timing`** para expor TTFB de origem.
- **`Configuration` cacheada** (`Cache::rememberForever('prefecture.config')`) no
  `default/master`; `Content::ultimasNoticias()` com eager loading.
- **JS delivery:** remoção de polyfills mortos (`html5shiv`, `respond`,
  `modernizr`), dedup de Bootstrap, `defer` em libs sem dependente inline
  síncrono, jQuery mantido síncrono, `rum.js` async.
- **Render containment:** `.cv-auto { content-visibility:auto;
  contain-intrinsic-size:auto 480px; }` em rodapés.
- **Monitoramento:** RUM + Speculation Rules + budget Lighthouse.

---

## 14. Métricas e critérios de aceite

| Critério | Como medir | Esperado |
|---|---|---|
| Compressão | `curl --compressed` + `comparar.py` | `compression=1.0` |
| Cache de borda | headers `X-Cache`/`CF-Cache-Status`/`Age` | `cache_policy≥80`, `cache_hit>90%` (estáticos) |
| Origem/TTFB | `Server-Timing` + harness | queda vs baseline |
| Revalidação | `If-None-Match` → 304 | `revalidation>0` |
| Transfer | `comparar.py` | queda (WebP + gzip + minify) |
| LCP/CLS/INP | Lighthouse/RUM | LCP≤2500, CLS≤0.1 |
| Segurança | headers + canário | `X-Hardening-Version`, CSP (se ligado), HSTS |

> O `cache_hit` só sobe de fato se a **borda Multi-CDN** estiver configurada para
> cachear HTML com `public` + `s-maxage` + sem `Set-Cookie` (track A4). Se a borda
> não estiver assim, `cache_policy` sobe mas `cache_hit` fica ~0.
