-
Notifications
You must be signed in to change notification settings - Fork 0
Language Negotiation
Rumen Damyanov edited this page Jul 31, 2025
·
1 revision
Advanced guide to handling multi-language support and language detection using geolocation data.
- Understanding Language Negotiation
- Browser Language Detection
- Country-Based Language Mapping
- Language Priority and Fallbacks
- Multi-Language Country Support
- Advanced Patterns
- Integration Examples
- Best Practices
Language negotiation combines multiple sources to determine the best language for a user:
- Geographic Location - Country-based language defaults
- Browser Preferences - Accept-Language header analysis
- User Preferences - Stored cookies or account settings
- Site Availability - What languages your site supports
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);
}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
)
*/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)
*/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
*/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'],
];
}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}";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"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)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];
}
}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)"
}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";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"// 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');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);
}
}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']
);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];
}
}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();- 🏗️ Symfony Integration - Event listeners and service configuration
- 📊 Client Detection - Browser, device, and resolution detection
- ⚙️ Configuration - Advanced configuration options
- 🌍 Multi-language Websites - Complete internationalization examples
Previous: Local Development Simulation | Next: Symfony Integration