- 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
4.7 KiB
4.7 KiB
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
- PHP-Server-Side-Rendering with
app/Locales/*.phparrays +t()helper (chosen) - JSON translation files + JS-driven i18n (rejected — FOUC, bad SEO)
- Full Symfony/translation component (rejected — overkill for 1-page site)
- 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-Languageheader → fallbackde). Immutable, no globals. Available locales:['de', 'en', 'uk', 'ru'].App\Core\I18n— translation loader +t(string $key, array $params = [])function. Loadsapp/Locales/{locale}.phplazily, caches in static array. Supports{placeholder}interpolation. Falls back todeif a key is missing in the current locale, then to the key itself.App\Controllers\LocaleController—GET /locale/{locale}sets a one-yearlocalecookie and 302-redirects toReferer(or/).app/Locales/{de,en,uk,ru}.php— flatkey => 'text'arrays. Keys use dotted notation (nav.gallery,hero.cta,form.error.email).- Layout —
app/views/layouts/main.phpreads current locale fromLocale::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
- Query parameter
?lang=xx(one-shot, sets cookie) - Cookie
locale(1 year,SameSite=Lax, noSecureflag for HTTP test hosts,Secureflag in prod via env check) Accept-Languageheader — first matching language from['en-US', 'en', 'uk', 'ru'](BCP-47 → ISO 639-1 mapping)- Fallback:
de
Translation File Format
// app/Locales/de.php
return [
'nav.gallery' => 'Galerie',
'hero.cta' => 'Jetzt anfragen',
'form.label.email' => 'E-Mail',
];
{placeholder} interpolation, e.g.:
'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} Zimmerstrings. Acceptable: page has fixed numbers (227 m²,6 Zimmer). - Flat key namespace (
nav.galleryvs 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: #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