# Phase 15: Locale Detection & Routing - Research **Researched:** 2026-02-02 **Domain:** WinterCMS locale routing, URL prefix patterns, browser detection, preference persistence **Confidence:** HIGH ## Summary This phase extends the existing Golem15\Translate plugin infrastructure to implement URL-based locale routing with automatic browser detection and persistent preference storage. The core infrastructure already exists and is well-implemented - LocaleMiddleware handles the priority cascade (URL > User DB > Session > Browser > Default), Translator manages locale state, and LocalePicker/AlternateHrefLangElements components are available. The primary work involves: (1) Setting the `locale_manually_set` cookie when switching locales via URL prefix or LocalePicker, (2) Creating a language suggestion banner component for mismatched browser languages, (3) Adding a language preference section to the account settings page, and (4) Integrating AlternateHrefLangElements into all layouts for SEO. **Primary recommendation:** Leverage existing plugin infrastructure completely. Focus on UI integration (language switcher in header, suggestion banner, account settings), cookie management enhancement, and ensuring logged-in users' preferences sync to DB. ## Standard Stack The established libraries/tools for this domain: ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Golem15\Translate | Current | Full translation infrastructure | Already in codebase, handles locales, middleware, components | | Golem15\User | Current | User model with `preferred_locale` field | Already has preference field, integrated with middleware | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | LocaleMiddleware | Current | Priority cascade for locale detection | Already registered in CmsController | | Translator | Current | Singleton for locale state management | Use for all locale operations | | LocalePicker | Current | Component for language switching | Embed in header | | AlternateHrefLangElements | Current | Generate hreflang tags | Add to all layouts | ### Already Configured The following is already configured in `/config/golem15/translate/config.php`: - `prefixDefaultLocale: false` - English (default) has no URL prefix - `disableLocalePrefixRoutes: false` - URL prefixes enabled - `browserDetection.enabled: true` - Browser detection active - `browserDetection.manualSelectionCookie: 'locale_manually_set'` - Cookie name defined **No additional package installation required.** ## Architecture Patterns ### Existing Infrastructure Overview ``` plugins/golem15/translate/ |-- classes/ | |-- LocaleMiddleware.php # Priority cascade: URL > User > Session > Browser > Default | |-- Translator.php # Singleton, setLocale/getLocale/loadLocaleFromRequest |-- components/ | |-- LocalePicker.php # onSwitchLocale handler, forceUrl property | |-- AlternateHrefLangElements.php # Generates hreflang tags |-- config/config.php # browserDetection settings |-- routes.php # Registers /{locale}/* route groups ``` ### Pattern 1: Locale Priority Cascade (Existing) **What:** The middleware implements a clear priority order for locale resolution. **When to use:** Already runs on every CMS request. **Implementation (from LocaleMiddleware.php):** ```php // Priority 1: URL prefix (explicit override) if (!$translator->loadLocaleFromRequest()) { // Priority 2: Authenticated user's preferred_locale from DB if (!$this->loadLocaleFromUser($translator)) { // Priority 3: Session (previous selection) $localeLoaded = $translator->loadLocaleFromSession(); // Priority 4: Browser Accept-Language (only without manual selection cookie) if (!$localeLoaded && !$this->hasManualLocaleSelection($request)) { $localeLoaded = $this->loadLocaleFromBrowser($translator, $request); } // Priority 5: Default locale fallback if (!$localeLoaded) { $translator->setLocale($translator->getDefaultLocale()); } } } ``` ### Pattern 2: URL Prefix Routing (Existing) **What:** Routes with locale prefix are registered dynamically. **When to use:** Automatically for any URL with `/pl/` or `/de/` prefix. **Implementation (from routes.php):** ```php $locale = $translator->getLocale(); Route::group(['prefix' => $locale, 'middleware' => 'web'], function () { Route::any('{slug?}', 'Cms\Classes\CmsController@run')->where('slug', '(.*)?'); }); ``` ### Pattern 3: LocalePicker Component Usage **What:** Provides locale switching with proper URL handling. **When to use:** In theme partials for language switcher. **Example:** ```twig {# In layout or header partial #} {% component 'localePicker' %} {# Custom implementation #}
``` ### Pattern 4: Manual Selection Cookie **What:** Cookie that prevents browser auto-detection after manual selection. **Gap Found:** Cookie is READ by LocaleMiddleware but NOT SET anywhere. **Required Enhancement:** ```php // In LocalePicker::onSwitchLocale() - set cookie Cookie::queue( Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'), '1', Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600) // 1 year ); // In routes.php - set cookie when URL prefix detected if ($translator->loadLocaleFromRequest()) { Cookie::queue('locale_manually_set', '1', 525600); } ``` ### Pattern 5: Hreflang Implementation **What:** Automatic generation of alternate language links for SEO. **When to use:** In all layouts' `` section. **Example:** ```twig {# In layout head #} {% component 'alternateHrefLangElements' %} {# Generates #} ``` ### Anti-Patterns to Avoid - **Auto-redirect without user consent:** Never silently redirect users based on browser language. Use suggestion banners instead. - **Coupling locale with location/currency:** Locale is language preference only. Don't assume location from language. - **Modal popups for language selection:** Use non-modal banners - users instinctively dismiss modals. - **Overwriting DB preference on URL prefix visit:** URL prefix should only affect the current session, not update the user's stored preference. ## Don't Hand-Roll Problems that look simple but have existing solutions: | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Locale detection from URL | Custom URL parser | `Translator::loadLocaleFromRequest()` | Handles edge cases, segment extraction | | Accept-Language parsing | Custom header parser | `LocaleMiddleware::parseAcceptLanguage()` | Handles quality values, malformed headers | | Locale validation | Custom list check | `Locale::isValid($locale)` | Uses cached locale list | | Session persistence | Custom session handling | `Translator::setLocale($locale, true)` | Second param enables session storage | | Hreflang generation | Custom link builder | `AlternateHrefLangElements` component | Handles static pages, CMS pages, URL params | **Key insight:** The Golem15\Translate plugin has comprehensive infrastructure. The only gaps are: (1) cookie setting on manual selection, (2) UI components for the theme, (3) user preference sync to DB. ## Common Pitfalls ### Pitfall 1: Cookie Not Being Set on Manual Selection **What goes wrong:** Users who switch language via picker or URL prefix still get browser detection on next visit. **Why it happens:** LocaleMiddleware reads `locale_manually_set` cookie but it's never written. **How to avoid:** Add cookie setting in LocalePicker::onSwitchLocale() and routes.php when URL prefix is detected. **Warning signs:** Browser detection triggers even after user has explicitly chosen a language. ### Pitfall 2: User Preference Not Syncing to Database **What goes wrong:** Logged-in users switch language but it's not saved to their profile. **Why it happens:** LocalePicker only sets session, doesn't update `User::preferred_locale`. **How to avoid:** Extend onSwitchLocale to update `Auth::getUser()->preferred_locale` if authenticated. **Warning signs:** User logs in on another device and sees wrong language. ### Pitfall 3: Hreflang Without x-default **What goes wrong:** Search engines don't know which URL to use for unmatched languages. **Why it happens:** Missing x-default hreflang tag. **How to avoid:** Ensure AlternateHrefLangElements or custom implementation includes `` pointing to default locale URL. **Warning signs:** Search Console shows hreflang warnings. ### Pitfall 4: Modal Popup for Language Suggestion **What goes wrong:** Users instinctively close modals without reading. **Why it happens:** Using modal instead of dismissable banner. **How to avoid:** Use sticky top/bottom banner that doesn't block interaction. **Warning signs:** High dismissal rates, low language switch conversions. ### Pitfall 5: Snowboard data-request-success **What goes wrong:** Callbacks fail silently. **Why it happens:** WinterCMS Snowboard framework removed `data-request-success` as unsafe (uses eval). **How to avoid:** Use `Snowboard.request()` with `success` callback function. **Warning signs:** No callback execution after AJAX request. ## Code Examples Verified patterns from existing codebase: ### Language Switcher Dropdown (Theme Implementation) ```twig {# themes/quotify/partials/language-switcher.htm #} {% set locales = localePicker.locales %} {% set activeLocale = localePicker.activeLocale %} {% set activeLocaleName = localePicker.activeLocaleName %}
``` ### Language Suggestion Banner Component ```php // plugins/golem15/translate/components/LocaleSuggestionBanner.php namespace Golem15\Translate\Components; use Cms\Classes\ComponentBase; use Golem15\Translate\Classes\Translator; use Golem15\Translate\Models\Locale; use Request; use Config; class LocaleSuggestionBanner extends ComponentBase { public function componentDetails(): array { return [ 'name' => 'Locale Suggestion Banner', 'description' => 'Shows banner when browser language differs from current page' ]; } public function onRun() { $this->page['showBanner'] = $this->shouldShowBanner(); $this->page['suggestedLocale'] = $this->getSuggestedLocale(); $this->page['suggestedLocaleName'] = $this->getSuggestedLocaleName(); } protected function shouldShowBanner(): bool { // Don't show if manually set if (Request::cookie('locale_manually_set')) { return false; } // Don't show if dismissed this session if (session('locale_banner_dismissed')) { return false; } // Don't show if 1-week dismissal cookie exists if (Request::cookie('locale_banner_dismissed')) { return false; } // Check if browser language differs from current return $this->getSuggestedLocale() !== null; } protected function getSuggestedLocale(): ?string { $browserLocale = $this->detectBrowserLocale(); $currentLocale = Translator::instance()->getLocale(); if ($browserLocale && $browserLocale !== $currentLocale) { return $browserLocale; } return null; } protected function detectBrowserLocale(): ?string { $acceptLanguage = Request::header('Accept-Language'); if (!$acceptLanguage) return null; $enabledLocales = array_keys(Locale::listEnabled()); $candidates = explode(',', $acceptLanguage); foreach ($candidates as $candidate) { $code = strtolower(substr(trim(explode(';', $candidate)[0]), 0, 2)); if (in_array($code, $enabledLocales)) { return $code; } } return null; } protected function getSuggestedLocaleName(): ?string { $locale = $this->getSuggestedLocale(); if (!$locale) return null; $locales = Locale::listEnabled(); return $locales[$locale] ?? null; } public function onDismissBanner() { session(['locale_banner_dismissed' => true]); // Set 1-week dismissal cookie \Cookie::queue('locale_banner_dismissed', '1', 60 * 24 * 7); // 7 days return ['success' => true]; } public function onSwitchToSuggested() { $locale = post('locale'); if (!Locale::isValid($locale)) { return; } Translator::instance()->setLocale($locale, true); // Set manual selection cookie \Cookie::queue( Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'), '1', Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600) ); return \Redirect::refresh(); } } ``` ### Banner Partial Template ```twig {# components/localesuggestionbanner/default.htm #} {% if showBanner and suggestedLocale %} {% endif %} ``` ### Syncing Preference to User Database ```php // Extend LocalePicker::onSwitchLocale in Plugin.php boot() \Golem15\Translate\Components\LocalePicker::extend(function($component) { $component->bindEvent('component.beforeRunAjaxHandler', function($handler) use ($component) { if ($handler === 'onSwitchLocale') { $locale = post('locale'); // Set manual selection cookie \Cookie::queue( \Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'), '1', \Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600) ); // Update user preference in DB if logged in if (\Auth::check()) { $user = \Auth::getUser(); $user->preferred_locale = $locale; $user->save(); } } }); }); ``` ### Account Settings Language Preference Section ```twig {# Add to account/update.htm - new section #}
``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Modal popups for language | Non-modal sticky banners | 2022+ | Better UX, lower dismissal rates | | Auto-redirect on language detection | Suggestion banner + user choice | Best practice | SEO-safe, respects user intent | | Coupling language/region/currency | Decoupled preferences | Industry standard | Users can mix preferences | **Deprecated/outdated:** - `data-request-success` attribute: Removed from WinterCMS Snowboard as unsafe (eval). Use `Snowboard.request()` with callback. ## Open Questions Things that couldn't be fully resolved: 1. **x-default hreflang tag** - What we know: AlternateHrefLangElements generates per-locale links - What's unclear: Whether it includes x-default automatically - Recommendation: Verify in testing, add manually if missing 2. **URL prefix cookie setting location** - What we know: Cookie should be set when URL has locale prefix - What's unclear: Best place to intercept (routes.php vs middleware) - Recommendation: Test in routes.php first; middleware as fallback ## Sources ### Primary (HIGH confidence) - `/plugins/golem15/translate/classes/LocaleMiddleware.php` - Priority cascade implementation - `/plugins/golem15/translate/classes/Translator.php` - Locale state management - `/plugins/golem15/translate/components/LocalePicker.php` - Component implementation - `/plugins/golem15/translate/components/AlternateHrefLangElements.php` - Hreflang generation - `/plugins/golem15/translate/config/config.php` - Default configuration - `/config/golem15/translate/config.php` - Project-specific config - `/plugins/golem15/user/models/User.php` - preferred_locale field ### Secondary (MEDIUM confidence) - [Smashing Magazine: Designing Better Language Selector](https://www.smashingmagazine.com/2022/05/designing-better-language-selector/) - UX patterns for language selectors - [Google Search Central: Localized Versions](https://developers.google.com/search/docs/specialty/international/localized-versions) - Hreflang best practices - [TranslatePress: Automatic User Language Detection](https://translatepress.com/docs/addons/automatic-user-language-detection/) - Pop-up vs redirect patterns ### Tertiary (LOW confidence) - WebSearch results on cookie banner patterns - General UX guidance ## Metadata **Confidence breakdown:** - Standard stack: HIGH - Direct inspection of existing codebase - Architecture: HIGH - Verified from actual plugin implementation - Pitfalls: HIGH - Identified from code gaps and CLAUDE.md constraints - Code examples: MEDIUM - Based on existing patterns, needs testing **Research date:** 2026-02-02 **Valid until:** 2026-03-02 (30 days - stable infrastructure)