Files
2026-02-18 01:31:41 +01:00

21 KiB

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):

// 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):

$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:

{# In layout or header partial #}
{% component 'localePicker' %}

{# Custom implementation #}
<div class="language-switcher">
    <button class="language-current">{{ activeLocaleName }}</button>
    <ul class="language-list">
        {% for code, name in locales %}
            <li>
                <a href="#"
                   data-request="localePicker::onSwitchLocale"
                   data-request-data="locale: '{{ code }}'">
                    {{ name }}
                </a>
            </li>
        {% endfor %}
    </ul>
</div>

What: Cookie that prevents browser auto-detection after manual selection. Gap Found: Cookie is READ by LocaleMiddleware but NOT SET anywhere. Required Enhancement:

// 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' <head> section. Example:

{# In layout head #}
{% component 'alternateHrefLangElements' %}

{# Generates #}
<link rel="alternate" hreflang="en" href="https://quotify.pro/jobs">
<link rel="alternate" hreflang="pl" href="https://quotify.pro/pl/jobs">
<link rel="alternate" hreflang="de" href="https://quotify.pro/de/jobs">

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

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 <link rel="alternate" hreflang="x-default"> 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)

{# themes/quotify/partials/language-switcher.htm #}
{% set locales = localePicker.locales %}
{% set activeLocale = localePicker.activeLocale %}
{% set activeLocaleName = localePicker.activeLocaleName %}

<div class="language-switcher" data-language-switcher>
    <button type="button" class="language-switcher-toggle" aria-expanded="false" aria-haspopup="true">
        <span class="language-switcher-label">{{ activeLocaleName }}</span>
        <svg class="language-switcher-icon" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
        </svg>
    </button>
    <ul class="language-switcher-list" role="menu">
        {% for code, name in locales %}
        <li role="none">
            <a href="#" role="menuitem"
               class="language-switcher-option {% if code == activeLocale %}active{% endif %}"
               data-request="localePicker::onSwitchLocale"
               data-request-data="locale: '{{ code }}'">
                {{ name }}
            </a>
        </li>
        {% endfor %}
    </ul>
</div>

Language Suggestion Banner Component

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

{# components/localesuggestionbanner/default.htm #}
{% if showBanner and suggestedLocale %}
<div class="locale-suggestion-banner" data-locale-banner role="alert">
    <div class="container">
        <div class="locale-suggestion-content">
            <p class="locale-suggestion-text">
                {% if suggestedLocale == 'pl' %}
                    {{ 'Ta strona jest dostepna po polsku'|_ }}
                {% elseif suggestedLocale == 'de' %}
                    {{ 'Diese Seite ist auf Deutsch verfügbar'|_ }}
                {% else %}
                    {{ 'This page is available in'|_ }} {{ suggestedLocaleName }}
                {% endif %}
            </p>
            <div class="locale-suggestion-actions">
                <button type="button"
                        class="btn btn-sm btn-primary"
                        data-request="{{ __SELF__ }}::onSwitchToSuggested"
                        data-request-data="locale: '{{ suggestedLocale }}'">
                    {% if suggestedLocale == 'pl' %}Przełącz na polski{% elseif suggestedLocale == 'de' %}Zu Deutsch wechseln{% else %}Switch{% endif %}
                </button>
                <button type="button"
                        class="btn btn-sm btn-link"
                        data-request="{{ __SELF__ }}::onDismissBanner"
                        data-request-update="{ '{{ __SELF__ }}': '@self' }">
                    <svg class="icon" viewBox="0 0 20 20" fill="currentColor">
                        <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
                    </svg>
                </button>
            </div>
        </div>
    </div>
</div>
{% endif %}

Syncing Preference to User Database

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

{# Add to account/update.htm - new section #}
<div class="account-section">
    <div class="account-section-header">
        <h3 class="account-section-title">{{ 'Language Preference'|_ }}</h3>
        <p class="account-section-description">{{ 'Choose your preferred language for the site.'|_ }}</p>
    </div>

    <div class="account-section-content">
        <div class="form-group">
            <label for="preferredLocale" class="form-label">{{ 'Preferred Language'|_ }}</label>
            <select name="preferred_locale" id="preferredLocale" class="form-control">
                {% set locales = {'en': 'English', 'pl': 'Polski', 'de': 'Deutsch'} %}
                {% for code, name in locales %}
                    <option value="{{ code }}" {% if user.preferred_locale == code %}selected{% endif %}>
                        {{ name }}
                    </option>
                {% endfor %}
            </select>
        </div>
    </div>
</div>

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)

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)