Accept-Language * negotiation -> fallback. Tenant default slots in between once tenant * settings expose one (Phase 3). */ class LocaleResolver { public const FALLBACK = 'en'; public const SETTING_KEY = 'core.locale'; public function __construct( private readonly UserAccountsService $userService, #[Inject('rootDir')] private readonly string $rootDir, ) {} /** * Locales with a core catalog on disk (public/l10n/*.json), always * including the fallback. This backs the language picker, so a locale * is only offered when it has at least core-shell coverage. * * @return string[] */ public function available(): array { $locales = [self::FALLBACK]; foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) { $locales[] = basename($file, '.json'); } $locales = array_values(array_unique($locales)); sort($locales); return $locales; } public function resolve(Request $request): string { $available = $this->available(); // 1. explicit user preference $settings = $this->userService->fetchSettings([self::SETTING_KEY]); $preference = $settings[self::SETTING_KEY] ?? null; if (is_string($preference) && in_array($preference, $available, true)) { return $preference; } // 2. Accept-Language negotiation. getPreferredLanguage() returns the // first candidate when nothing matches, so the fallback leads the // list; it also normalizes to underscore form (pt_BR) while catalogs // use BCP-47 hyphens. $candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK])); $negotiated = $request->getPreferredLanguage($candidates); if ($negotiated !== null) { $negotiated = str_replace('_', '-', $negotiated); if (in_array($negotiated, $available, true)) { return $negotiated; } } // 3. fallback return self::FALLBACK; } }