# ADR-012 — i18n multilíngue (pt_BR, en, es, fr) — nativo e JSONB

| Campo | Valor |
|---|---|
| **Status** | Aceito (F3) |
| **Data** | 2026-10-14 |
| **Plano de origem** | `wiki/plan/03-esqueleto-ddd-lite.md` |
| **Relacionadas** | ADR-001, ADR-017, ADR-016, ADR-010 |

## Contexto

O projeto nunca tratou internacionalização. É preciso suportar **pelo menos inglês, espanhol e
francês** (além do português padrão) da forma **mais simples** e alinhada às decisões: **nativo
Laravel primeiro** (ADR-001), **zero dependências/CDN**, **conteúdo em JSONB** (ADR-017), **tema
como dado** (ADR-016) e **acessibilidade** (`lang` — WCAG 3.1).

## Decisão

1. **Locales:** `pt_BR` (default), `en`, `es`, `fr`; `fallback_locale = pt_BR`.
2. **Interface (strings) — a tradução acompanha quem é dono do fragmento.** Laravel **nativo**
   (`lang/{locale}/*.php` + `__()`/`@lang`), carregado **por convenção** pelo Core:
   - **`src/layout` é só estrutura** (shell + espaços/slots) e é dono das strings do **shell**
     (`header/nav/footer`).
   - **Módulo:** `src/modules/{Grupo}/{Modulo}/Lang/{locale}/*.php`, com **namespace = token
     canônico** (ADR-004). O módulo **puxa o layout** e é dono da tradução **do fragmento que ele
     publica no espaço** (ex.: o conteúdo do `<main>` da página de notícias) — na pasta próxima às
     views que o usam.
   - **Componentes compartilhados** (botões, paginação, rótulos a11y, erros RFC 7807) são donos da
     própria tradução (`core`/`layout`) — **nunca duplicados** por módulo.
   - **Service:** `src/services/{Nome}/Lang/{locale}/*.php` (idem).
   - **Regra:** nenhuma string fixa solta no HTML; cada string mora no **dono do fragmento**. **Sem pacote.**
3. **Conteúdo (domínio):** campos traduzíveis em **JSONB keyed by locale**
   (`{"pt_BR":"…","en":"…","es":"…","fr":"…"}`), com **um Cast nativo** (`LocaleCast`):
   - **leitura** (`get`) devolve o valor do **locale atual** com **fallback** para o default;
   - **escrita** (`set`) grava o **objeto completo** (o CMS posta todas as locales) — **não** adivinha
     o locale pela aba;
   - **CMS** lê as traduções cruas (`getTranslations('campo')`) para o indicador de faltante.
   **Nunca HTML** (ADR-017); **sem `spatie/laravel-translatable`** (dependência + trait).
4. **Roteamento/SEO — o locale vem da URL.** **Default no root** (`/noticias`), demais com prefixo
   (`/en/noticias`, `/es/noticias`, `/fr/noticias`); `hreflang` alternates no `<head>`; canônica por
   locale. A **URL é a autoridade** do locale — cacheável e determinística (ADR-001/022).
5. **Resolução de locale (Core middleware):** **URL → default**, **sem sessão/cookie** no portal.
   A **área CMS** pode ter o idioma do **usuário** (campo do usuário, aplicado só no CMS, não
   cacheado). `Accept-Language` no máximo como **302** em `/`; **nunca** como estado de requisição pública.
6. **Formatação:** `intl` nativo (`IntlDateFormatter`/`NumberFormatter`) + `Carbon::setLocale`;
   sem bibliotecas.
7. **Slugs:** por locale em JSONB; URL antiga sem prefixo → **301** para o locale resolvido.
8. **CMS — aba de tradução do conteúdo:** o editor de dados do módulo tem uma **aba por locale**
   (pt_BR/en/es/fr) para o autor transcrever o **conteúdo** (campo `jsonb` + `LocaleCast`);
   indicador de tradução faltante e fallback visível. A tradução **fixa** do layout vem do `Lang/`
   do módulo (item 2), **não** do CMS.
9. **Acessibilidade:** `<html lang>` (WCAG 3.1.1) e `lang` em trechos de outro idioma (3.1.2);
   propriedades lógicas (ADR-010) já preparam RTL.
10. **Grep-friendly:** namespace de tradução = token do módulo/service.

## Justificativa (alternativas descartadas)

- **Pacote de tradução (`spatie/laravel-translatable`)** — descartada: dependência + trait;
  o **cast nativo** cobre o caso.
- **Tabela `translations` por campo** — descartada: mais joins/complexidade; o conteúdo já é JSONB.
- **Tudo prefixado (`/pt/...`)** — descartada: muda URLs legadas e duplica SEO; default no root.
- **Libs de i18n JS** — descartada: o HTML já é renderizado no servidor.

## Consequências

**Positivas**
- Zero dependências (Laravel + `intl`); um só middleware; conteúdo e interface no mesmo modelo mental.
- SEO correto (`hreflang`) e a11y (`lang`) desde o início.

**Custos / negativas**
- Campos traduzíveis passam a ser JSONB (o CMS precisa editar por locale).
- Tradução de conteúdo exige disciplina editorial (indicador de faltante no CMS).

## Exemplo mínimo (comparativo)

```php
// ❌ Dependência + trait para traduzir conteúdo
use Spatie\Translatable\HasTranslations;
class Noticia extends Model { use HasTranslations; public array $translatable = ['titulo']; }

// ✅ Cast nativo + JSONB (sem trait, sem pacote)
final class LocaleCast implements CastsAttributes {
    public function get($model, $key, $value, $attributes): ?string {
        $all = json_decode($value ?? '{}', true) ?: [];
        return $all[app()->getLocale()] ?? $all[config('app.fallback_locale')] ?? null;
    }
    public function set($model, $key, $value, $attributes): string {
        $all = json_decode($attributes[$key] ?? '{}', true) ?: [];
        $all[app()->getLocale()] = $value;
        return json_encode($all, JSON_UNESCAPED_UNICODE);
    }
}
// interface (nativo):
__('noticia::messages.published');   // lang/{locale}/messages.php do módulo
```

## Verificação

- `pt_BR`, `en`, `es`, `fr` renderizam a mesma página com `<html lang>` correto e `hreflang`.
- Cada módulo resolve `__('{token}::…')` a partir do seu próprio `Lang/` (sem tocar o root);
  nenhuma string fixa de fragmento hardcoded na view.
- **Teste estrutural** (estilo `ModulesTelemetryTest`): todo módulo com `Layout/Views` declara
  `Lang/{locale}/{token}.php` (pt_BR/en/es/fr); strings do **shell** moram no layout, não no módulo.
- No CMS, a aba de tradução grava o conteúdo por locale (JSONB) e o portal mostra o valor do locale atual.
- Campo traduzível resolve no locale atual e cai no default quando faltar.
- `Accept-Language` sem prefixo resolve o locale; URL antiga → 301.
- Nenhuma dependência de i18n no `composer.json`; `tools/vendor-check` verde.

## Referências

- Laravel i18n (`lang/`, `loadTranslationsFrom`, `CastsAttributes`); `intl` (plano 04);
  `wiki/specs/13-i18n.md`.
