Skip to content

Language Negotiation

Rumen Damyanov edited this page Jul 31, 2025 · 1 revision

Language Negotiation

Advanced guide to handling multi-language support and language detection using geolocation data.

Table of Contents

Understanding Language Negotiation

Language negotiation combines multiple sources to determine the best language for a user:

  1. Geographic Location - Country-based language defaults
  2. Browser Preferences - Accept-Language header analysis
  3. User Preferences - Stored cookies or account settings
  4. Site Availability - What languages your site supports

Priority Order

function negotiateLanguage($geo, $availableLanguages) {
    // 1. Explicit user choice (highest priority)
    if ($userChoice = getUserLanguageChoice()) {
        return $userChoice;
    }

    // 2. Stored cookie preference
    if ($cookieLanguage = getCookieLanguage()) {
        return $cookieLanguage;
    }

    // 3. Geographic location + browser preference
    $country = $geo->getCountryCode();
    $browserLanguages = $geo->getAcceptedLanguages();

    return findBestMatch($country, $browserLanguages, $availableLanguages);
}

Browser Language Detection

Basic Accept-Language Parsing

use Rumenx\Geolocation\Geolocation;

$geo = new Geolocation();

// Get preferred language from browser
$preferredLanguage = $geo->getPreferredLanguage();
echo "Browser prefers: {$preferredLanguage}";
// Output: "Browser prefers: en-US"

// Get all accepted languages with quality values
$allLanguages = $geo->getAllLanguages();
print_r($allLanguages);
/* Output:
Array (
    [0] => en-US
    [1] => en
    [2] => fr
    [3] => de
)
*/

Advanced Browser Language Analysis

function parseAcceptLanguageHeader($acceptLanguage) {
    $languages = [];
    $parts = explode(',', $acceptLanguage);

    foreach ($parts as $part) {
        $part = trim($part);
        $langData = explode(';', $part);
        $language = trim($langData[0]);

        // Parse quality value (default 1.0)
        $quality = 1.0;
        if (isset($langData[1]) && strpos($langData[1], 'q=') === 0) {
            $quality = floatval(substr($langData[1], 2));
        }

        $languages[] = [
            'language' => $language,
            'quality' => $quality,
            'primary' => explode('-', $language)[0], // e.g., 'en' from 'en-US'
        ];
    }

    // Sort by quality (highest first)
    usort($languages, function($a, $b) {
        return $b['quality'] <=> $a['quality'];
    });

    return $languages;
}

// Example usage
$acceptLanguage = 'en-US,en;q=0.9,fr;q=0.8,de;q=0.7';
$browserLanguages = parseAcceptLanguageHeader($acceptLanguage);

foreach ($browserLanguages as $lang) {
    echo "{$lang['language']} (quality: {$lang['quality']})\n";
}

/* Output:
en-US (quality: 1)
en (quality: 0.9)
fr (quality: 0.8)
de (quality: 0.7)
*/

Regional Language Variants

function normalizeLanguageCode($language) {
    $language = strtolower($language);

    // Handle common variants
    $variants = [
        'en-us' => 'en',
        'en-gb' => 'en',
        'en-ca' => 'en',
        'en-au' => 'en',
        'fr-fr' => 'fr',
        'fr-ca' => 'fr',
        'de-de' => 'de',
        'de-at' => 'de',
        'de-ch' => 'de',
        'es-es' => 'es',
        'es-mx' => 'es',
        'pt-br' => 'pt',
        'pt-pt' => 'pt',
        'zh-cn' => 'zh',
        'zh-tw' => 'zh',
    ];

    return $variants[$language] ?? explode('-', $language)[0];
}

// Test regional variants
$testLanguages = ['en-US', 'fr-CA', 'de-AT', 'es-MX'];
foreach ($testLanguages as $lang) {
    echo "{$lang}" . normalizeLanguageCode($lang) . "\n";
}

/* Output:
en-US → en
fr-CA → fr
de-AT → de
es-MX → es
*/

Country-Based Language Mapping

Comprehensive Country Mapping

function getComprehensiveCountryMapping() {
    return [
        // English-speaking countries
        'US' => ['en'],
        'GB' => ['en'],
        'CA' => ['en', 'fr'],    // Bilingual
        'AU' => ['en'],
        'NZ' => ['en'],
        'IE' => ['en'],
        'ZA' => ['en'],

        // European languages
        'DE' => ['de'],
        'AT' => ['de'],
        'CH' => ['de', 'fr', 'it'],  // Multilingual
        'LI' => ['de'],
        'LU' => ['fr', 'de'],
        'FR' => ['fr'],
        'BE' => ['fr', 'nl'],
        'MC' => ['fr'],
        'ES' => ['es'],
        'PT' => ['pt'],
        'IT' => ['it'],
        'SM' => ['it'],
        'VA' => ['it'],
        'NL' => ['nl'],
        'NO' => ['no'],
        'SE' => ['sv'],
        'DK' => ['da'],
        'FI' => ['fi'],
        'IS' => ['is'],

        // Eastern Europe
        'RU' => ['ru'],
        'PL' => ['pl'],
        'CZ' => ['cs'],
        'SK' => ['sk'],
        'HU' => ['hu'],
        'RO' => ['ro'],
        'BG' => ['bg'],
        'HR' => ['hr'],
        'SI' => ['sl'],
        'EE' => ['et'],
        'LV' => ['lv'],
        'LT' => ['lt'],

        // Asian languages
        'JP' => ['ja'],
        'CN' => ['zh'],
        'KR' => ['ko'],
        'TH' => ['th'],
        'VN' => ['vi'],
        'ID' => ['id'],
        'MY' => ['ms'],
        'SG' => ['en', 'zh', 'ms'],
        'PH' => ['en'],
        'IN' => ['en', 'hi'],

        // Middle East & Africa
        'TR' => ['tr'],
        'AR' => ['ar'],
        'SA' => ['ar'],
        'AE' => ['ar'],
        'EG' => ['ar'],
        'IL' => ['he'],
        'IR' => ['fa'],

        // Americas
        'MX' => ['es'],
        'BR' => ['pt'],
        'AR' => ['es'],
        'CL' => ['es'],
        'CO' => ['es'],
        'PE' => ['es'],
        'VE' => ['es'],
        'UY' => ['es'],
        'PY' => ['es'],
        'BO' => ['es'],
        'EC' => ['es'],
    ];
}

Smart Language Detection

class SmartLanguageDetector
{
    private $countryMapping;
    private $availableLanguages;

    public function __construct($availableLanguages = ['en']) {
        $this->countryMapping = getComprehensiveCountryMapping();
        $this->availableLanguages = $availableLanguages;
    }

    public function detectBestLanguage(Geolocation $geo) {
        $country = $geo->getCountryCode();
        $browserLanguages = $this->parseBrowserLanguages($geo);

        // 1. Try exact browser language match
        foreach ($browserLanguages as $browserLang) {
            if (in_array($browserLang['language'], $this->availableLanguages)) {
                return $browserLang['language'];
            }
        }

        // 2. Try primary language match (en from en-US)
        foreach ($browserLanguages as $browserLang) {
            if (in_array($browserLang['primary'], $this->availableLanguages)) {
                return $browserLang['primary'];
            }
        }

        // 3. Try country-based languages
        if ($country && isset($this->countryMapping[$country])) {
            $countryLanguages = $this->countryMapping[$country];

            foreach ($countryLanguages as $countryLang) {
                if (in_array($countryLang, $this->availableLanguages)) {
                    return $countryLang;
                }
            }
        }

        // 4. Fallback to first available language
        return $this->availableLanguages[0];
    }

    private function parseBrowserLanguages(Geolocation $geo) {
        $acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
        return parseAcceptLanguageHeader($acceptLanguage);
    }
}

// Usage example
$detector = new SmartLanguageDetector(['en', 'fr', 'de', 'es']);
$geo = new Geolocation();
$bestLanguage = $detector->detectBestLanguage($geo);

echo "Detected best language: {$bestLanguage}";

Language Priority and Fallbacks

Quality-Based Selection

function selectLanguageByQuality($browserLanguages, $availableLanguages) {
    $scores = [];

    foreach ($availableLanguages as $available) {
        $scores[$available] = 0;

        foreach ($browserLanguages as $browser) {
            $browserLang = $browser['language'];
            $quality = $browser['quality'];

            // Exact match
            if ($browserLang === $available) {
                $scores[$available] += $quality * 1.0;
            }
            // Primary language match (en matches en-US)
            elseif ($browser['primary'] === $available) {
                $scores[$available] += $quality * 0.8;
            }
            // Partial match (en-US matches en-GB)
            elseif (strpos($browserLang, $available . '-') === 0) {
                $scores[$available] += $quality * 0.6;
            }
        }
    }

    // Return language with highest score
    arsort($scores);
    return array_key_first($scores);
}

// Example
$browserLanguages = [
    ['language' => 'fr-CA', 'quality' => 1.0, 'primary' => 'fr'],
    ['language' => 'en', 'quality' => 0.8, 'primary' => 'en'],
    ['language' => 'es', 'quality' => 0.6, 'primary' => 'es'],
];

$availableLanguages = ['en', 'fr', 'de'];
$selected = selectLanguageByQuality($browserLanguages, $availableLanguages);

echo "Selected language: {$selected}"; // Output: "Selected language: fr"

Fallback Chain

class LanguageFallbackChain
{
    private $fallbacks = [
        'pt-BR' => ['pt', 'es', 'en'],
        'zh-CN' => ['zh', 'en'],
        'zh-TW' => ['zh', 'en'],
        'ar-SA' => ['ar', 'en'],
        'fa-IR' => ['fa', 'ar', 'en'],
        'he-IL' => ['he', 'en'],
        'hi-IN' => ['hi', 'en'],
        'ja-JP' => ['ja', 'en'],
        'ko-KR' => ['ko', 'en'],
        'th-TH' => ['th', 'en'],
        'vi-VN' => ['vi', 'en'],
    ];

    public function getFallbackChain($language) {
        // Check for exact fallback mapping
        if (isset($this->fallbacks[$language])) {
            return $this->fallbacks[$language];
        }

        // Generate automatic fallback
        $primary = explode('-', $language)[0];
        $fallbacks = [$primary];

        // Add common fallbacks based on language family
        if (in_array($primary, ['es', 'pt', 'it', 'fr'])) {
            $fallbacks[] = 'en'; // Romance languages → English
        } elseif (in_array($primary, ['de', 'nl', 'sv', 'no', 'da'])) {
            $fallbacks[] = 'en'; // Germanic languages → English
        } else {
            $fallbacks[] = 'en'; // Default fallback
        }

        return array_unique($fallbacks);
    }

    public function selectWithFallback($requestedLanguage, $availableLanguages) {
        $fallbackChain = $this->getFallbackChain($requestedLanguage);

        foreach ($fallbackChain as $fallback) {
            if (in_array($fallback, $availableLanguages)) {
                return $fallback;
            }
        }

        // Ultimate fallback
        return $availableLanguages[0] ?? 'en';
    }
}

// Usage
$fallback = new LanguageFallbackChain();
$selected = $fallback->selectWithFallback('pt-BR', ['en', 'es', 'fr']);
echo "Selected: {$selected}"; // Output: "Selected: es" (Portuguese → Spanish fallback)

Multi-Language Country Support

Complex Country Scenarios

class MultiLanguageCountryHandler
{
    private $complexCountries = [
        'CH' => [
            'languages' => ['de', 'fr', 'it'],
            'regions' => [
                'de' => ['ZH', 'BE', 'LU', 'BS', 'AG'], // German-speaking cantons
                'fr' => ['GE', 'VD', 'NE', 'JU'],       // French-speaking cantons
                'it' => ['TI'],                          // Italian-speaking canton
            ],
            'default' => 'de'
        ],
        'CA' => [
            'languages' => ['en', 'fr'],
            'regions' => [
                'fr' => ['QC'],     // Quebec
                'en' => ['ON', 'BC', 'AB', 'SK', 'MB', 'NS', 'NB', 'PE', 'NL'],
            ],
            'default' => 'en'
        ],
        'BE' => [
            'languages' => ['fr', 'nl', 'de'],
            'regions' => [
                'fr' => ['WAL'],    // Wallonia
                'nl' => ['VLG'],    // Flanders
                'de' => ['DG'],     // German-speaking community
            ],
            'default' => 'fr'
        ],
        'SG' => [
            'languages' => ['en', 'zh', 'ms', 'ta'],
            'default' => 'en'
        ]
    ];

    public function getLanguageForCountry($country, $browserLanguages, $availableLanguages) {
        if (!isset($this->complexCountries[$country])) {
            // Simple country - use standard mapping
            return $this->getStandardLanguage($country, $availableLanguages);
        }

        $countryData = $this->complexCountries[$country];
        $countryLanguages = $countryData['languages'];

        // 1. Try to match browser preference with country languages
        foreach ($browserLanguages as $browserLang) {
            $primary = explode('-', $browserLang['language'])[0];

            if (in_array($primary, $countryLanguages) &&
                in_array($primary, $availableLanguages)) {
                return $primary;
            }
        }

        // 2. Use country default if available
        $default = $countryData['default'];
        if (in_array($default, $availableLanguages)) {
            return $default;
        }

        // 3. Use first available country language
        foreach ($countryLanguages as $lang) {
            if (in_array($lang, $availableLanguages)) {
                return $lang;
            }
        }

        // 4. Fallback to first available
        return $availableLanguages[0];
    }

    private function getStandardLanguage($country, $availableLanguages) {
        $mapping = getComprehensiveCountryMapping();

        if (isset($mapping[$country])) {
            foreach ($mapping[$country] as $lang) {
                if (in_array($lang, $availableLanguages)) {
                    return $lang;
                }
            }
        }

        return $availableLanguages[0];
    }
}

Regional Sub-Language Detection

function detectRegionalLanguage($country, $browserLanguages) {
    $regionalMappings = [
        'US' => [
            'en-US' => 'American English',
            'es-US' => 'US Spanish',
        ],
        'CA' => [
            'en-CA' => 'Canadian English',
            'fr-CA' => 'Canadian French',
        ],
        'GB' => [
            'en-GB' => 'British English',
        ],
        'AU' => [
            'en-AU' => 'Australian English',
        ],
        'MX' => [
            'es-MX' => 'Mexican Spanish',
        ],
        'BR' => [
            'pt-BR' => 'Brazilian Portuguese',
        ],
        'PT' => [
            'pt-PT' => 'European Portuguese',
        ],
    ];

    if (!isset($regionalMappings[$country])) {
        return null;
    }

    $countryVariants = $regionalMappings[$country];

    foreach ($browserLanguages as $browserLang) {
        if (isset($countryVariants[$browserLang['language']])) {
            return [
                'language' => $browserLang['language'],
                'variant' => $countryVariants[$browserLang['language']],
                'quality' => $browserLang['quality']
            ];
        }
    }

    return null;
}

// Example usage
$geo = new Geolocation();
$country = $geo->getCountryCode(); // 'CA'
$browserLanguages = parseAcceptLanguageHeader('fr-CA,fr;q=0.9,en;q=0.8');

$regional = detectRegionalLanguage($country, $browserLanguages);
if ($regional) {
    echo "Detected: {$regional['language']} ({$regional['variant']})";
    // Output: "Detected: fr-CA (Canadian French)"
}

Advanced Patterns

Language Context Manager

class LanguageContextManager
{
    private $contexts = [];
    private $currentContext = 'default';

    public function addContext($name, $availableLanguages, $fallback = 'en') {
        $this->contexts[$name] = [
            'languages' => $availableLanguages,
            'fallback' => $fallback
        ];
    }

    public function setContext($name) {
        if (isset($this->contexts[$name])) {
            $this->currentContext = $name;
        }
    }

    public function getLanguageForContext(Geolocation $geo, $context = null) {
        $context = $context ?? $this->currentContext;

        if (!isset($this->contexts[$context])) {
            throw new InvalidArgumentException("Context '{$context}' not found");
        }

        $contextData = $this->contexts[$context];
        $detector = new SmartLanguageDetector($contextData['languages']);

        return $detector->detectBestLanguage($geo) ?? $contextData['fallback'];
    }
}

// Usage example
$manager = new LanguageContextManager();

// Define different contexts
$manager->addContext('main_site', ['en', 'fr', 'de', 'es']);
$manager->addContext('admin_panel', ['en', 'fr']);
$manager->addContext('api_docs', ['en']);
$manager->addContext('support', ['en', 'fr', 'de', 'es', 'pt', 'it']);

$geo = new Geolocation();

// Get language for different contexts
echo "Main site: " . $manager->getLanguageForContext($geo, 'main_site') . "\n";
echo "Admin panel: " . $manager->getLanguageForContext($geo, 'admin_panel') . "\n";
echo "API docs: " . $manager->getLanguageForContext($geo, 'api_docs') . "\n";
echo "Support: " . $manager->getLanguageForContext($geo, 'support') . "\n";

Weighted Language Selection

class WeightedLanguageSelector
{
    private $weights = [
        'user_preference' => 10,    // Highest priority
        'cookie' => 8,
        'geographic' => 6,
        'browser_exact' => 5,
        'browser_primary' => 3,
        'default' => 1             // Lowest priority
    ];

    public function selectLanguage($criteria, $availableLanguages) {
        $scores = [];

        // Initialize scores
        foreach ($availableLanguages as $lang) {
            $scores[$lang] = 0;
        }

        // Apply weighted criteria
        foreach ($criteria as $type => $languages) {
            $weight = $this->weights[$type] ?? 1;

            foreach ($languages as $lang => $confidence) {
                if (isset($scores[$lang])) {
                    $scores[$lang] += $weight * $confidence;
                }
            }
        }

        // Return highest scoring language
        arsort($scores);
        return array_key_first($scores);
    }
}

// Example usage
$selector = new WeightedLanguageSelector();

$criteria = [
    'geographic' => ['de' => 1.0],           // From Germany
    'browser_exact' => ['de-DE' => 0.9],     // Browser wants German
    'browser_primary' => ['en' => 0.7],      // Browser also accepts English
    'default' => ['en' => 1.0],              // Site default
];

$availableLanguages = ['en', 'de', 'fr'];
$selected = $selector->selectLanguage($criteria, $availableLanguages);

echo "Selected language: {$selected}"; // Output: "Selected language: de"

Integration Examples

WordPress Integration

// WordPress theme functions.php
function setup_geolocation_language() {
    if (!is_admin()) {
        $geo = new \Rumenx\Geolocation\Geolocation();
        $country = $geo->getCountryCode();

        // Map countries to WordPress locales
        $localeMap = [
            'DE' => 'de_DE',
            'FR' => 'fr_FR',
            'ES' => 'es_ES',
            'IT' => 'it_IT',
            'PT' => 'pt_PT',
            'BR' => 'pt_BR',
            'NL' => 'nl_NL',
            'RU' => 'ru_RU',
            'JP' => 'ja',
            'CN' => 'zh_CN',
        ];

        if (isset($localeMap[$country])) {
            add_filter('locale', function() use ($localeMap, $country) {
                return $localeMap[$country];
            });
        }
    }
}
add_action('init', 'setup_geolocation_language');

Custom CMS Integration

class CMSLanguageIntegration
{
    private $geo;
    private $session;

    public function __construct($sessionManager) {
        $this->geo = new Geolocation();
        $this->session = $sessionManager;
    }

    public function initializeLanguage() {
        $language = $this->detectLanguage();

        // Set in session
        $this->session->set('cms_language', $language);

        // Set PHP locale
        setlocale(LC_ALL, $this->getLocaleForLanguage($language));

        // Load translations
        $this->loadTranslations($language);

        return $language;
    }

    private function detectLanguage() {
        // Check URL parameter first
        if (isset($_GET['lang'])) {
            $lang = $_GET['lang'];
            if ($this->isValidLanguage($lang)) {
                return $lang;
            }
        }

        // Check session
        if ($sessionLang = $this->session->get('cms_language')) {
            if ($this->isValidLanguage($sessionLang)) {
                return $sessionLang;
            }
        }

        // Use geolocation
        $country = $this->geo->getCountryCode();
        return $this->geo->getLanguageForCountry($country) ?? 'en';
    }

    private function getLocaleForLanguage($language) {
        $locales = [
            'en' => 'en_US.UTF-8',
            'fr' => 'fr_FR.UTF-8',
            'de' => 'de_DE.UTF-8',
            'es' => 'es_ES.UTF-8',
            'it' => 'it_IT.UTF-8',
            'pt' => 'pt_PT.UTF-8',
            'ru' => 'ru_RU.UTF-8',
            'ja' => 'ja_JP.UTF-8',
            'zh' => 'zh_CN.UTF-8',
        ];

        return $locales[$language] ?? 'en_US.UTF-8';
    }

    private function loadTranslations($language) {
        $translationFile = __DIR__ . "/translations/{$language}.json";

        if (file_exists($translationFile)) {
            $translations = json_decode(file_get_contents($translationFile), true);
            $GLOBALS['translations'] = $translations;
        }
    }

    private function isValidLanguage($language) {
        $supportedLanguages = ['en', 'fr', 'de', 'es', 'it', 'pt', 'ru', 'ja', 'zh'];
        return in_array($language, $supportedLanguages);
    }
}

Best Practices

Performance Optimization

class OptimizedLanguageDetector
{
    private static $cache = [];
    private static $cacheLifetime = 3600; // 1 hour

    public static function detectWithCache($cacheKey, Geolocation $geo, $availableLanguages) {
        // Check cache first
        if (isset(self::$cache[$cacheKey])) {
            $cached = self::$cache[$cacheKey];
            if (time() - $cached['timestamp'] < self::$cacheLifetime) {
                return $cached['language'];
            }
        }

        // Detect language
        $detector = new SmartLanguageDetector($availableLanguages);
        $language = $detector->detectBestLanguage($geo);

        // Cache result
        self::$cache[$cacheKey] = [
            'language' => $language,
            'timestamp' => time()
        ];

        return $language;
    }

    public static function clearCache() {
        self::$cache = [];
    }
}

// Usage
$cacheKey = 'user_' . session_id();
$language = OptimizedLanguageDetector::detectWithCache(
    $cacheKey,
    $geo,
    ['en', 'fr', 'de']
);

Error Handling

function safeLanguageDetection(Geolocation $geo, $availableLanguages, $fallback = 'en') {
    try {
        $detector = new SmartLanguageDetector($availableLanguages);
        return $detector->detectBestLanguage($geo);
    } catch (Exception $e) {
        // Log error for debugging
        error_log("Language detection failed: " . $e->getMessage());

        // Return safe fallback
        return in_array($fallback, $availableLanguages) ? $fallback : $availableLanguages[0];
    }
}

Testing Language Negotiation

function testLanguageNegotiation() {
    $testCases = [
        [
            'name' => 'German user, German available',
            'country' => 'DE',
            'accept_language' => 'de-DE,de;q=0.9,en;q=0.8',
            'available' => ['en', 'de', 'fr'],
            'expected' => 'de'
        ],
        [
            'name' => 'French Canadian, French not available',
            'country' => 'CA',
            'accept_language' => 'fr-CA,fr;q=0.9,en;q=0.8',
            'available' => ['en', 'es'],
            'expected' => 'en'
        ],
        [
            'name' => 'Swiss user, multiple languages',
            'country' => 'CH',
            'accept_language' => 'de-CH,de;q=0.9,fr;q=0.8,it;q=0.7',
            'available' => ['en', 'fr', 'it'],
            'expected' => 'fr'
        ]
    ];

    foreach ($testCases as $case) {
        $_SERVER['HTTP_CF_IPCOUNTRY'] = $case['country'];
        $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $case['accept_language'];

        $geo = Geolocation::simulate($case['country']);
        $detector = new SmartLanguageDetector($case['available']);
        $result = $detector->detectBestLanguage($geo);

        $status = $result === $case['expected'] ? '' : '';
        echo "{$status} {$case['name']}: Expected {$case['expected']}, Got {$result}\n";
    }
}

testLanguageNegotiation();

Next Steps


Previous: Local Development Simulation | Next: Symfony Integration

Clone this wiki locally