Files
landingpage-haus-schleusingen/docs/adr/002-multilanguage-architecture.md
Hermes 63c8c759d2 feat(i18n): core Locale resolver + I18n t()-helper with tests (closes #72)
- App\Core\Locale: query-param > cookie > Accept-Language > 'de' fallback
  - BCP-47 region stripping (en-US -> en, uk-UA -> uk)
  - q-value sorting with stable order
  - og:locale mapping (de_DE, en_GB, uk_UA, ru_RU)
  - hreflang alternates helper
- App\Core\I18n: t() with {placeholder} interpolation, lookup chain
  current-locale -> de -> key, in-memory cache
- ADR-002: documents the architecture decision
- 46 PHPUnit tests (LocaleTest, I18nTest), all green
2026-06-04 08:53:58 +00:00

124 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-002: Multi-Language Architecture (i18n)
**Status:** Accepted
**Date:** 2026-06-04
**Context:** Issue #71 (Epic: Multi-Language: UK/RU/EN — DE bleibt für Rechtliches)
**Deciders:** Martin (Product Owner), Hermes (Implementation)
## Context and Problem Statement
The landing page `landingpage-haus-schleusingen.de` is currently German-only.
It must support 4 languages: **DE** (default, for legal content), **EN** (UK
English), **UK** (Ukrainian), **RU** (Russian). The site is SEO-critical (real
estate listing), has no build step, and runs on stock PHP 8.x on shared hosting.
The challenge: ship server-side rendering for SEO + no FOUC, without dragging
in a heavy framework or build pipeline.
## Considered Options
1. **PHP-Server-Side-Rendering with `app/Locales/*.php` arrays + `t()` helper**
(chosen)
2. JSON translation files + JS-driven i18n (rejected — FOUC, bad SEO)
3. Full Symfony/translation component (rejected — overkill for 1-page site)
4. Static-site per language (`/de/`, `/en/`, `/uk/`, `/ru/` directories)
(rejected — duplicates routes/forms, harder to maintain)
## Decision
**Option 1: PHP SSR with `app/Locales/*.php` and a `t()` helper.**
### Components
- **`App\Core\Locale`** — locale resolution (priority: query-param `?lang=`
→ cookie → `Accept-Language` header → fallback `de`). Immutable, no
globals. Available locales: `['de', 'en', 'uk', 'ru']`.
- **`App\Core\I18n`** — translation loader + `t(string $key, array $params = [])`
function. Loads `app/Locales/{locale}.php` lazily, caches in static array.
Supports `{placeholder}` interpolation. Falls back to `de` if a key is
missing in the current locale, then to the key itself.
- **`App\Controllers\LocaleController`** — `GET /locale/{locale}` sets a
one-year `locale` cookie and 302-redirects to `Referer` (or `/`).
- **`app/Locales/{de,en,uk,ru}.php`** — flat `key => 'text'` arrays. Keys use
dotted notation (`nav.gallery`, `hero.cta`, `form.error.email`).
- **Layout** — `app/views/layouts/main.php` reads current locale from
`Locale::current()` and renders dynamic `<html lang="…">` + `og:locale`.
- **Switcher UI** — in `app/views/partials/locale_switcher.php`, embedded in
navbar. Inline SVG flag icons (no external assets).
### Locale Resolution Order
1. Query parameter `?lang=xx` (one-shot, sets cookie)
2. Cookie `locale` (1 year, `SameSite=Lax`, no `Secure` flag for HTTP test
hosts, `Secure` flag in prod via env check)
3. `Accept-Language` header — first matching language from
`['en-US', 'en', 'uk', 'ru']` (BCP-47 → ISO 639-1 mapping)
4. Fallback: `de`
### Translation File Format
```php
// app/Locales/de.php
return [
'nav.gallery' => 'Galerie',
'hero.cta' => 'Jetzt anfragen',
'form.label.email' => 'E-Mail',
];
```
`{placeholder}` interpolation, e.g.:
```php
'greeting' => 'Hallo, {name}!',
echo t('greeting', ['name' => 'Martin']); // "Hallo, Martin!"
```
### Out of Scope (this MVP)
- Right-to-left languages (Arabic, Hebrew)
- Plural forms (`{n,plural,one{...}other{...}}` ICU syntax) — flat strings only
- Admin UI for editing translations (POEditor, Crowdin, etc.)
- Per-page translation overrides
- URL-based locale (`/en/`, `/uk/`) — cookie + query only for MVP
### Trade-offs Accepted
- **No URL-based locale** → weaker SEO signal for non-default languages.
Mitigation: `og:locale` + `<html lang>` + hreflang tags (TODO post-MVP).
- **No ICU plural** → manual `{n} Zimmer` strings. Acceptable: page has
fixed numbers (`227 m²`, `6 Zimmer`).
- **Flat key namespace** (`nav.gallery` vs nested arrays) → slightly more
verbose but trivially diff-able in PRs and avoids PHP array-merging
surprises.
## Consequences
### Positive
- **Zero new dependencies** (no Composer additions, no JS framework)
- **SEO-perfect** — fully server-rendered, no FOUC
- **Trivially testable** — pure PHP, no globals, no I/O at request time
(files loaded once, cached)
- **Diff-friendly** — translation files are flat PHP arrays
- **Fast** — locale detection is in-memory; translation load happens once
per request, per locale
### Negative
- Adding a new key requires touching all 4 files (mitigated by `missing
key → fallback to DE → fallback to key string`)
- No URL canonicalization for non-DE locales (mitigated post-MVP with
hreflang)
- Manual translation review (no professional translator for UK/RU in MVP)
## Implementation Plan
Issue #71 (Epic) → #72#77 (6 sub-issues, dependency-ordered).
## References
- Issue #71: https://git.home.kies-media.de/greggy/landingpage-haus-schleusingen/issues/71
- Issue #72#77: sub-issues, all in Milestone "Multi-Language MVP"
- W3C i18n tutorials: https://www.w3.org/International/tutorials/
- BCP-47 language tags: https://datatracker.ietf.org/doc/html/rfc5646