'text'` arrays from `app/Locales/{locale}.php` once per * request per locale, caches in static memory. Supports {placeholder} * interpolation. * * Fallback chain: current locale → 'de' → key string itself (with optional * missing-key indicator in dev). * * Stateless on the instance — `t()` is a static method so views can call * it without a container. */ final class I18n { /** @var array> locale => [key => text] */ private static array $cache = []; /** @var string|null Path to the Locales directory (overridable for tests) */ private static ?string $localesPath = null; /** * Translate a key in the current locale, with {placeholder} interpolation. * * @param string $key Dotted key, e.g. 'nav.gallery' * @param array $params Placeholders: ['name' => 'Martin'] * @param string|null $locale Override locale (defaults to current) */ public static function t(string $key, array $params = [], ?string $locale = null): string { $locale ??= Locale::DEFAULT; // Unsupported locale = likely a developer bug; surface the key // rather than silently falling back to DE. if (!Locale::isSupported($locale)) { return self::interpolate($key, $params); } $text = self::lookup($key, $locale); if ($text === null && $locale !== Locale::DEFAULT) { $text = self::lookup($key, Locale::DEFAULT); } $text ??= $key; return self::interpolate($text, $params); } /** * Plural-aware translation. MVP: no ICU — we just append `{n}` to the * key so the caller provides singular and plural variants. * * @param array $params */ public static function tn(string $keySingular, string $keyPlural, int $n, array $params = [], ?string $locale = null): string { $key = $n === 1 ? $keySingular : $keyPlural; $params = array_merge($params, ['n' => (string) $n]); return self::t($key, $params, $locale); } /** * Check whether a key exists in the given locale (or the default). * Useful for tests and conditional UI logic. */ public static function has(string $key, ?string $locale = null): bool { $locale ??= Locale::DEFAULT; return self::lookup($key, $locale) !== null || ($locale !== Locale::DEFAULT && self::lookup($key, Locale::DEFAULT) !== null); } /** * Reset the in-memory cache. Test-only utility. */ public static function flushCache(): void { self::$cache = []; } /** * Override the Locales directory. Test-only utility. */ public static function setLocalesPath(string $path): void { self::$localesPath = $path; } /** * Look up a key in a specific locale's array. */ private static function interpolate(string $text, array $params): string { if ($params === []) { return $text; } $search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params)); $replace = array_values($params); return str_replace($search, $replace, $text); } private static function lookup(string $key, string $locale): ?string { if (!Locale::isSupported($locale)) { return null; } if (!isset(self::$cache[$locale])) { $file = self::localesPath() . '/' . $locale . '.php'; if (!is_file($file)) { self::$cache[$locale] = []; return null; } $data = require $file; self::$cache[$locale] = is_array($data) ? $data : []; } return self::$cache[$locale][$key] ?? null; } private static function localesPath(): string { if (self::$localesPath !== null) { return self::$localesPath; } return dirname(__DIR__) . '/Locales'; } }