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

4.7 KiB
Raw Blame History

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\LocaleControllerGET /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).
  • Layoutapp/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

// 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} 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