Skip to content

Multilang Websites

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

Multi-language Websites

Complete guide for building international applications with automatic language detection, content localization, and geographic personalization using php-geolocation.

Table of Contents

Architecture Overview

Multi-language Application Structure

Multi-language Application
├── Language Detection Layer
│   ├── Geographic Detection (Country → Language)
│   ├── Browser Language Headers
│   ├── User Preferences (Cookie/Session)
│   └── URL Path Analysis
├── Content Layer
│   ├── Localized Templates
│   ├── Translation Database
│   ├── Geographic Content Rules
│   └── Fallback Content
├── SEO Layer
│   ├── Hreflang Tags
│   ├── Localized URLs
│   ├── Geographic Sitemaps
│   └── Search Console Setup
└── User Experience Layer
    ├── Language Switcher
    ├── Regional Preferences
    ├── Localized Forms
    └── Cultural Adaptations

Core Multi-language Class

<?php
// src/MultilangManager.php
namespace Rumenx\Geolocation;

use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;

class MultilangManager
{
    private Geolocation $geo;
    private CacheItemPoolInterface $cache;
    private LoggerInterface $logger;
    private array $config;
    private string $currentLanguage;
    private string $currentCountry;
    private array $supportedLanguages;
    private array $countryLanguages;

    public function __construct(
        Geolocation $geo,
        CacheItemPoolInterface $cache,
        LoggerInterface $logger,
        array $config = []
    ) {
        $this->geo = $geo;
        $this->cache = $cache;
        $this->logger = $logger;
        $this->config = array_merge($this->getDefaultConfig(), $config);

        $this->supportedLanguages = $this->config['supported_languages'];
        $this->countryLanguages = $this->config['country_languages'];

        $this->detectLanguage();
    }

    private function getDefaultConfig(): array
    {
        return [
            'supported_languages' => ['en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'zh', 'ar'],
            'default_language' => 'en',
            'fallback_language' => 'en',
            'country_languages' => [
                'US' => ['en'],
                'CA' => ['en', 'fr'],
                'MX' => ['es'],
                'BR' => ['pt'],
                'AR' => ['es'],
                'GB' => ['en'],
                'IE' => ['en'],
                'DE' => ['de'],
                'AT' => ['de'],
                'CH' => ['de', 'fr', 'it'],
                'FR' => ['fr'],
                'BE' => ['fr', 'nl'],
                'ES' => ['es'],
                'IT' => ['it'],
                'PT' => ['pt'],
                'NL' => ['nl'],
                'RU' => ['ru'],
                'JP' => ['ja'],
                'CN' => ['zh'],
                'KR' => ['ko'],
                'IN' => ['hi', 'en'],
                'SA' => ['ar']
            ],
            'detection_priority' => [
                'url_parameter',
                'user_preference',
                'geographic',
                'browser_header',
                'default'
            ],
            'url_patterns' => [
                'subdomain' => '{lang}.domain.com',
                'path' => 'domain.com/{lang}/',
                'parameter' => 'domain.com?lang={lang}'
            ],
            'cookie_name' => 'preferred_language',
            'cookie_lifetime' => 86400 * 365, // 1 year
            'cache_ttl' => 3600
        ];
    }

    private function detectLanguage(): void
    {
        $this->currentCountry = $this->geo->getCountryCode();

        foreach ($this->config['detection_priority'] as $method) {
            $language = $this->detectByMethod($method);
            if ($language && $this->isLanguageSupported($language)) {
                $this->currentLanguage = $language;
                $this->logger->info('Language detected', [
                    'language' => $language,
                    'method' => $method,
                    'country' => $this->currentCountry
                ]);
                return;
            }
        }

        $this->currentLanguage = $this->config['fallback_language'];
        $this->logger->warning('Language detection failed, using fallback', [
            'fallback' => $this->currentLanguage,
            'country' => $this->currentCountry
        ]);
    }

    private function detectByMethod(string $method): ?string
    {
        switch ($method) {
            case 'url_parameter':
                return $this->detectFromUrl();
            case 'user_preference':
                return $this->detectFromPreferences();
            case 'geographic':
                return $this->detectFromGeography();
            case 'browser_header':
                return $this->detectFromBrowser();
            case 'default':
                return $this->config['default_language'];
            default:
                return null;
        }
    }

    private function detectFromUrl(): ?string
    {
        // Check URL parameter
        if (isset($_GET['lang'])) {
            return strtolower(trim($_GET['lang']));
        }

        // Check path-based language
        $path = trim($_SERVER['REQUEST_URI'] ?? '', '/');
        $segments = explode('/', $path);
        if (!empty($segments[0]) && strlen($segments[0]) === 2) {
            return strtolower($segments[0]);
        }

        // Check subdomain
        $host = $_SERVER['HTTP_HOST'] ?? '';
        if (preg_match('/^([a-z]{2})\./', $host, $matches)) {
            return strtolower($matches[1]);
        }

        return null;
    }

    private function detectFromPreferences(): ?string
    {
        // Check session
        if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['language'])) {
            return $_SESSION['language'];
        }

        // Check cookie
        $cookieName = $this->config['cookie_name'];
        return $_COOKIE[$cookieName] ?? null;
    }

    private function detectFromGeography(): ?string
    {
        $countryLanguages = $this->countryLanguages[$this->currentCountry] ?? [];

        // Return the first supported language for this country
        foreach ($countryLanguages as $language) {
            if ($this->isLanguageSupported($language)) {
                return $language;
            }
        }

        return null;
    }

    private function detectFromBrowser(): ?string
    {
        $acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
        if (!$acceptLanguage) {
            return null;
        }

        // Parse Accept-Language header
        preg_match_all('/([a-z]{1,8}(?:-[a-z]{1,8})?)(?:;q=([0-9.]+))?/i', $acceptLanguage, $matches, PREG_SET_ORDER);

        $languages = [];
        foreach ($matches as $match) {
            $lang = strtolower(substr($match[1], 0, 2)); // Get language code only
            $quality = isset($match[2]) ? floatval($match[2]) : 1.0;
            $languages[$lang] = $quality;
        }

        // Sort by quality score
        arsort($languages);

        // Return first supported language
        foreach (array_keys($languages) as $lang) {
            if ($this->isLanguageSupported($lang)) {
                return $lang;
            }
        }

        return null;
    }

    public function getCurrentLanguage(): string
    {
        return $this->currentLanguage;
    }

    public function getCurrentCountry(): string
    {
        return $this->currentCountry;
    }

    public function setLanguage(string $language): bool
    {
        if (!$this->isLanguageSupported($language)) {
            return false;
        }

        $this->currentLanguage = $language;

        // Save to session
        if (session_status() === PHP_SESSION_ACTIVE) {
            $_SESSION['language'] = $language;
        }

        // Save to cookie
        setcookie(
            $this->config['cookie_name'],
            $language,
            time() + $this->config['cookie_lifetime'],
            '/',
            '',
            true, // Secure
            true  // HttpOnly
        );

        $this->logger->info('Language changed by user', [
            'language' => $language,
            'country' => $this->currentCountry
        ]);

        return true;
    }

    public function getSupportedLanguages(): array
    {
        return $this->supportedLanguages;
    }

    public function getAvailableLanguagesForCountry(): array
    {
        $countryLanguages = $this->countryLanguages[$this->currentCountry] ?? [];
        return array_intersect($countryLanguages, $this->supportedLanguages);
    }

    public function isLanguageSupported(string $language): bool
    {
        return in_array(strtolower($language), $this->supportedLanguages);
    }

    public function generateLanguageUrls(): array
    {
        $urls = [];
        $currentUrl = $_SERVER['REQUEST_URI'] ?? '/';

        foreach ($this->supportedLanguages as $lang) {
            $urls[$lang] = $this->buildLanguageUrl($lang, $currentUrl);
        }

        return $urls;
    }

    private function buildLanguageUrl(string $language, string $currentUrl): string
    {
        $pattern = $this->config['url_patterns']['path']; // Default to path-based

        if (strpos($pattern, '{lang}') !== false) {
            // Remove existing language from URL
            $cleanUrl = preg_replace('/^\/[a-z]{2}\//', '/', $currentUrl);
            return '/' . $language . $cleanUrl;
        }

        return $currentUrl . (strpos($currentUrl, '?') ? '&' : '?') . 'lang=' . $language;
    }

    public function getRegionalConfig(): array
    {
        $cacheKey = "regional_config_{$this->currentCountry}_{$this->currentLanguage}";
        $cached = $this->cache->getItem($cacheKey);

        if ($cached->isHit()) {
            return $cached->get();
        }

        $config = $this->buildRegionalConfig();

        $cached->set($config);
        $cached->expiresAfter($this->config['cache_ttl']);
        $this->cache->save($cached);

        return $config;
    }

    private function buildRegionalConfig(): array
    {
        $config = [
            'language' => $this->currentLanguage,
            'country' => $this->currentCountry,
            'currency' => $this->getCurrencyForCountry(),
            'timezone' => $this->getTimezoneForCountry(),
            'date_format' => $this->getDateFormatForCountry(),
            'number_format' => $this->getNumberFormatForCountry(),
            'rtl' => $this->isRightToLeft(),
            'locale' => $this->getLocaleCode()
        ];

        return $config;
    }

    private function getCurrencyForCountry(): string
    {
        $currencies = [
            'US' => 'USD', 'CA' => 'CAD', 'MX' => 'MXN', 'BR' => 'BRL',
            'GB' => 'GBP', 'IE' => 'EUR', 'DE' => 'EUR', 'FR' => 'EUR',
            'ES' => 'EUR', 'IT' => 'EUR', 'PT' => 'EUR', 'NL' => 'EUR',
            'AT' => 'EUR', 'BE' => 'EUR', 'CH' => 'CHF', 'RU' => 'RUB',
            'JP' => 'JPY', 'CN' => 'CNY', 'KR' => 'KRW', 'IN' => 'INR',
            'AU' => 'AUD', 'NZ' => 'NZD', 'SA' => 'SAR'
        ];

        return $currencies[$this->currentCountry] ?? 'USD';
    }

    private function getTimezoneForCountry(): string
    {
        $timezones = [
            'US' => 'America/New_York', 'CA' => 'America/Toronto',
            'MX' => 'America/Mexico_City', 'BR' => 'America/Sao_Paulo',
            'GB' => 'Europe/London', 'IE' => 'Europe/Dublin',
            'DE' => 'Europe/Berlin', 'FR' => 'Europe/Paris',
            'ES' => 'Europe/Madrid', 'IT' => 'Europe/Rome',
            'PT' => 'Europe/Lisbon', 'NL' => 'Europe/Amsterdam',
            'AT' => 'Europe/Vienna', 'BE' => 'Europe/Brussels',
            'CH' => 'Europe/Zurich', 'RU' => 'Europe/Moscow',
            'JP' => 'Asia/Tokyo', 'CN' => 'Asia/Shanghai',
            'KR' => 'Asia/Seoul', 'IN' => 'Asia/Kolkata',
            'AU' => 'Australia/Sydney', 'NZ' => 'Pacific/Auckland'
        ];

        return $timezones[$this->currentCountry] ?? 'UTC';
    }

    private function getDateFormatForCountry(): string
    {
        $formats = [
            'US' => 'M/d/Y', 'CA' => 'Y-m-d', 'GB' => 'd/m/Y',
            'DE' => 'd.m.Y', 'FR' => 'd/m/Y', 'ES' => 'd/m/Y',
            'IT' => 'd/m/Y', 'PT' => 'd-m-Y', 'NL' => 'd-m-Y',
            'JP' => 'Y/m/d', 'CN' => 'Y-m-d', 'KR' => 'Y. m. d.'
        ];

        return $formats[$this->currentCountry] ?? 'Y-m-d';
    }

    private function getNumberFormatForCountry(): array
    {
        $formats = [
            'US' => ['decimal' => '.', 'thousands' => ','],
            'DE' => ['decimal' => ',', 'thousands' => '.'],
            'FR' => ['decimal' => ',', 'thousands' => ' '],
            'ES' => ['decimal' => ',', 'thousands' => '.'],
            'IT' => ['decimal' => ',', 'thousands' => '.']
        ];

        return $formats[$this->currentCountry] ?? ['decimal' => '.', 'thousands' => ','];
    }

    private function isRightToLeft(): bool
    {
        $rtlLanguages = ['ar', 'he', 'fa', 'ur'];
        return in_array($this->currentLanguage, $rtlLanguages);
    }

    private function getLocaleCode(): string
    {
        return $this->currentLanguage . '_' . $this->currentCountry;
    }
}

Language Detection Strategy

Detection Priority Configuration

<?php
// Example detection strategies for different scenarios

// E-commerce site prioritizing user preferences
$ecommerceConfig = [
    'detection_priority' => [
        'user_preference',    // Returning customers
        'url_parameter',      // Marketing campaigns
        'geographic',         // First-time visitors
        'browser_header',     // Fallback
        'default'
    ]
];

// Content site prioritizing geographic relevance
$contentConfig = [
    'detection_priority' => [
        'geographic',         // Location-relevant content
        'user_preference',    // User choice override
        'browser_header',     // Browser settings
        'url_parameter',      // Direct links
        'default'
    ]
];

// Corporate site with explicit language selection
$corporateConfig = [
    'detection_priority' => [
        'url_parameter',      // Explicit selection
        'user_preference',    // Previous selection
        'default'             // Corporate default
    ]
];

// Usage example
$multilang = new MultilangManager($geo, $cache, $logger, $ecommerceConfig);

Advanced Language Detection

<?php
// src/AdvancedLanguageDetector.php
class AdvancedLanguageDetector extends MultilangManager
{
    private array $languageRules = [];
    private array $businessRules = [];

    public function addLanguageRule(string $name, callable $rule): void
    {
        $this->languageRules[$name] = $rule;
    }

    public function addBusinessRule(string $name, callable $rule): void
    {
        $this->businessRules[$name] = $rule;
    }

    protected function detectByMethod(string $method): ?string
    {
        // Apply custom rules first
        foreach ($this->languageRules as $name => $rule) {
            $result = $rule($method, $this->currentCountry, $_SERVER);
            if ($result) {
                $this->logger->info('Language detected by custom rule', [
                    'rule' => $name,
                    'language' => $result
                ]);
                return $result;
            }
        }

        // Apply business rules
        foreach ($this->businessRules as $name => $rule) {
            $result = $rule($method, $this->currentCountry, $_SERVER);
            if ($result) {
                $this->logger->info('Language overridden by business rule', [
                    'rule' => $name,
                    'language' => $result
                ]);
                return $result;
            }
        }

        return parent::detectByMethod($method);
    }
}

// Example custom rules
$detector = new AdvancedLanguageDetector($geo, $cache, $logger, $config);

// Rule: Force English for API endpoints
$detector->addLanguageRule('api_english', function($method, $country, $server) {
    $uri = $server['REQUEST_URI'] ?? '';
    if (strpos($uri, '/api/') === 0) {
        return 'en';
    }
    return null;
});

// Rule: Force local language for specific marketing campaigns
$detector->addBusinessRule('campaign_localization', function($method, $country, $server) {
    $campaign = $_GET['utm_campaign'] ?? '';
    if ($campaign === 'local_promo' && in_array($country, ['DE', 'FR', 'ES'])) {
        $campaigns = ['DE' => 'de', 'FR' => 'fr', 'ES' => 'es'];
        return $campaigns[$country];
    }
    return null;
});

// Rule: B2B customers always get English
$detector->addBusinessRule('b2b_english', function($method, $country, $server) {
    $userAgent = $server['HTTP_USER_AGENT'] ?? '';
    if (strpos($userAgent, 'B2BClient') !== false) {
        return 'en';
    }
    return null;
});

URL Structure Patterns

Path-based URLs

<?php
// src/PathBasedUrls.php
class PathBasedUrls
{
    private MultilangManager $multilang;
    private array $routes = [];

    public function __construct(MultilangManager $multilang)
    {
        $this->multilang = $multilang;
    }

    public function addRoute(string $pattern, callable $handler, array $languages = []): void
    {
        $this->routes[] = [
            'pattern' => $pattern,
            'handler' => $handler,
            'languages' => $languages ?: $this->multilang->getSupportedLanguages()
        ];
    }

    public function handleRequest(): void
    {
        $uri = trim($_SERVER['REQUEST_URI'] ?? '', '/');
        $segments = explode('/', $uri);

        // Extract language from first segment
        $language = null;
        if (!empty($segments[0]) && strlen($segments[0]) === 2) {
            $language = strtolower($segments[0]);
            array_shift($segments); // Remove language from path
        }

        // Set language if valid
        if ($language && $this->multilang->isLanguageSupported($language)) {
            $this->multilang->setLanguage($language);
        } else {
            // Redirect to language-specific URL
            $currentLang = $this->multilang->getCurrentLanguage();
            $redirectUrl = '/' . $currentLang . '/' . implode('/', $segments);
            header("Location: $redirectUrl", true, 302);
            exit;
        }

        // Match route
        $path = implode('/', $segments);
        foreach ($this->routes as $route) {
            if ($this->matchRoute($route['pattern'], $path)) {
                $currentLang = $this->multilang->getCurrentLanguage();
                if (in_array($currentLang, $route['languages'])) {
                    call_user_func($route['handler'], $path, $currentLang);
                    return;
                }
            }
        }

        // 404 handler
        http_response_code(404);
        echo "Page not found";
    }

    private function matchRoute(string $pattern, string $path): bool
    {
        $pattern = str_replace('*', '.*', $pattern);
        return preg_match("#^{$pattern}$#", $path);
    }

    public function generateUrl(string $path, string $language = null): string
    {
        $language = $language ?: $this->multilang->getCurrentLanguage();
        return '/' . $language . '/' . ltrim($path, '/');
    }

    public function generateCanonicalUrl(string $path): string
    {
        $defaultLang = 'en'; // or from config
        return $this->generateUrl($path, $defaultLang);
    }

    public function generateHreflangTags(string $path): string
    {
        $tags = [];
        $languages = $this->multilang->getSupportedLanguages();

        foreach ($languages as $lang) {
            $url = $this->generateUrl($path, $lang);
            $tags[] = "<link rel=\"alternate\" hreflang=\"{$lang}\" href=\"https://{$_SERVER['HTTP_HOST']}{$url}\" />";
        }

        // Add x-default
        $defaultUrl = $this->generateCanonicalUrl($path);
        $tags[] = "<link rel=\"alternate\" hreflang=\"x-default\" href=\"https://{$_SERVER['HTTP_HOST']}{$defaultUrl}\" />";

        return implode("\n", $tags);
    }
}

// Usage
$pathUrls = new PathBasedUrls($multilang);

// Define routes
$pathUrls->addRoute('', function($path, $lang) {
    include "views/{$lang}/home.php";
});

$pathUrls->addRoute('products/*', function($path, $lang) {
    include "views/{$lang}/products.php";
});

$pathUrls->addRoute('about', function($path, $lang) {
    include "views/{$lang}/about.php";
}, ['en', 'fr', 'de']); // Limited languages

$pathUrls->handleRequest();

Subdomain-based URLs

<?php
// src/SubdomainUrls.php
class SubdomainUrls
{
    private MultilangManager $multilang;
    private string $baseDomain;
    private array $subdomainMap = [];

    public function __construct(MultilangManager $multilang, string $baseDomain)
    {
        $this->multilang = $multilang;
        $this->baseDomain = $baseDomain;

        // Map languages to subdomains
        $this->subdomainMap = [
            'en' => 'www',
            'es' => 'es',
            'fr' => 'fr',
            'de' => 'de',
            'it' => 'it',
            'pt' => 'pt',
            'ru' => 'ru',
            'ja' => 'ja',
            'zh' => 'zh',
            'ar' => 'ar'
        ];
    }

    public function detectLanguageFromSubdomain(): ?string
    {
        $host = $_SERVER['HTTP_HOST'] ?? '';

        foreach ($this->subdomainMap as $lang => $subdomain) {
            if ($host === "{$subdomain}.{$this->baseDomain}") {
                return $lang;
            }
        }

        return null;
    }

    public function redirectToCorrectSubdomain(): void
    {
        $currentLang = $this->multilang->getCurrentLanguage();
        $expectedSubdomain = $this->subdomainMap[$currentLang] ?? 'www';
        $currentHost = $_SERVER['HTTP_HOST'] ?? '';
        $expectedHost = "{$expectedSubdomain}.{$this->baseDomain}";

        if ($currentHost !== $expectedHost) {
            $uri = $_SERVER['REQUEST_URI'] ?? '/';
            $redirectUrl = "https://{$expectedHost}{$uri}";

            header("Location: $redirectUrl", true, 302);
            exit;
        }
    }

    public function generateLanguageUrls(): array
    {
        $urls = [];
        $uri = $_SERVER['REQUEST_URI'] ?? '/';

        foreach ($this->multilang->getSupportedLanguages() as $lang) {
            $subdomain = $this->subdomainMap[$lang] ?? 'www';
            $urls[$lang] = "https://{$subdomain}.{$this->baseDomain}{$uri}";
        }

        return $urls;
    }

    public function generateHreflangTags(): string
    {
        $tags = [];
        $uri = $_SERVER['REQUEST_URI'] ?? '/';

        foreach ($this->generateLanguageUrls() as $lang => $url) {
            $tags[] = "<link rel=\"alternate\" hreflang=\"{$lang}\" href=\"{$url}\" />";
        }

        // Add x-default (usually English/www)
        $defaultUrl = "https://www.{$this->baseDomain}{$uri}";
        $tags[] = "<link rel=\"alternate\" hreflang=\"x-default\" href=\"{$defaultUrl}\" />";

        return implode("\n", $tags);
    }
}

// Usage
$subdomainUrls = new SubdomainUrls($multilang, 'example.com');

// Detect language from subdomain
$detectedLang = $subdomainUrls->detectLanguageFromSubdomain();
if ($detectedLang) {
    $multilang->setLanguage($detectedLang);
}

// Ensure user is on correct subdomain
$subdomainUrls->redirectToCorrectSubdomain();

Content Management

Localized Content Manager

<?php
// src/LocalizedContentManager.php
class LocalizedContentManager
{
    private MultilangManager $multilang;
    private PDO $db;
    private CacheItemPoolInterface $cache;
    private array $contentCache = [];

    public function __construct(MultilangManager $multilang, PDO $db, CacheItemPoolInterface $cache)
    {
        $this->multilang = $multilang;
        $this->db = $db;
        $this->cache = $cache;
    }

    public function getContent(string $key, array $params = []): string
    {
        $language = $this->multilang->getCurrentLanguage();
        $cacheKey = "content_{$key}_{$language}_" . md5(serialize($params));

        // Check memory cache
        if (isset($this->contentCache[$cacheKey])) {
            return $this->interpolateParams($this->contentCache[$cacheKey], $params);
        }

        // Check distributed cache
        $cached = $this->cache->getItem($cacheKey);
        if ($cached->isHit()) {
            $content = $cached->get();
            $this->contentCache[$cacheKey] = $content;
            return $this->interpolateParams($content, $params);
        }

        // Load from database
        $content = $this->loadContentFromDatabase($key, $language);

        // Cache the result
        $cached->set($content);
        $cached->expiresAfter(3600);
        $this->cache->save($cached);

        $this->contentCache[$cacheKey] = $content;
        return $this->interpolateParams($content, $params);
    }

    private function loadContentFromDatabase(string $key, string $language): string
    {
        $stmt = $this->db->prepare("
            SELECT content FROM localized_content
            WHERE content_key = ? AND language = ? AND active = 1
        ");
        $stmt->execute([$key, $language]);
        $result = $stmt->fetchColumn();

        if ($result !== false) {
            return $result;
        }

        // Fallback to default language
        $fallbackLang = $this->multilang->config['fallback_language'] ?? 'en';
        if ($language !== $fallbackLang) {
            $stmt->execute([$key, $fallbackLang]);
            $result = $stmt->fetchColumn();

            if ($result !== false) {
                return $result;
            }
        }

        // Return key if no content found
        return "{{$key}}";
    }

    private function interpolateParams(string $content, array $params): string
    {
        if (empty($params)) {
            return $content;
        }

        $search = [];
        $replace = [];

        foreach ($params as $key => $value) {
            $search[] = '{' . $key . '}';
            $replace[] = $value;
        }

        return str_replace($search, $replace, $content);
    }

    public function getLocalizedPage(string $page): array
    {
        $language = $this->multilang->getCurrentLanguage();
        $country = $this->multilang->getCurrentCountry();

        $stmt = $this->db->prepare("
            SELECT
                p.title, p.content, p.meta_description, p.meta_keywords,
                p.custom_data
            FROM pages p
            LEFT JOIN page_localizations pl ON p.id = pl.page_id
            WHERE p.slug = ? AND (pl.language = ? OR pl.language IS NULL)
            ORDER BY pl.language DESC
            LIMIT 1
        ");
        $stmt->execute([$page, $language]);
        $result = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$result) {
            throw new ContentNotFoundException("Page '{$page}' not found for language '{$language}'");
        }

        // Apply regional customizations
        $result['currency'] = $this->multilang->getRegionalConfig()['currency'];
        $result['timezone'] = $this->multilang->getRegionalConfig()['timezone'];
        $result['date_format'] = $this->multilang->getRegionalConfig()['date_format'];

        return $result;
    }

    public function getLocalizedProducts(array $filters = []): array
    {
        $language = $this->multilang->getCurrentLanguage();
        $country = $this->multilang->getCurrentCountry();
        $currency = $this->multilang->getRegionalConfig()['currency'];

        $sql = "
            SELECT
                p.id, p.sku, p.slug,
                COALESCE(pl.name, p.name) as name,
                COALESCE(pl.description, p.description) as description,
                COALESCE(pr.price, p.base_price) as price,
                pr.currency
            FROM products p
            LEFT JOIN product_localizations pl ON p.id = pl.product_id AND pl.language = ?
            LEFT JOIN product_regional_pricing pr ON p.id = pr.product_id AND pr.country = ?
            WHERE p.active = 1
        ";

        $params = [$language, $country];

        // Add filters
        if (!empty($filters['category'])) {
            $sql .= " AND p.category_id = ?";
            $params[] = $filters['category'];
        }

        if (!empty($filters['available_in_country'])) {
            $sql .= " AND p.id IN (
                SELECT product_id FROM product_availability
                WHERE country = ? AND available = 1
            )";
            $params[] = $country;
        }

        $sql .= " ORDER BY p.sort_order, p.id";

        $stmt = $this->db->prepare($sql);
        $stmt->execute($params);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function getLocalizedCategories(): array
    {
        $language = $this->multilang->getCurrentLanguage();

        $stmt = $this->db->prepare("
            SELECT
                c.id, c.slug, c.parent_id,
                COALESCE(cl.name, c.name) as name,
                COALESCE(cl.description, c.description) as description
            FROM categories c
            LEFT JOIN category_localizations cl ON c.id = cl.category_id AND cl.language = ?
            WHERE c.active = 1
            ORDER BY c.sort_order
        ");
        $stmt->execute([$language]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    public function invalidateContent(string $key = null): void
    {
        if ($key) {
            // Invalidate specific content across all languages
            $pattern = "content_{$key}_*";
            $this->cache->invalidate($pattern);

            // Clear from memory cache
            foreach (array_keys($this->contentCache) as $cacheKey) {
                if (strpos($cacheKey, "content_{$key}_") === 0) {
                    unset($this->contentCache[$cacheKey]);
                }
            }
        } else {
            // Invalidate all content
            $this->cache->invalidate('content_*');
            $this->contentCache = [];
        }
    }
}

class ContentNotFoundException extends Exception {}

Database Design

Localization Tables

-- Core content table
CREATE TABLE localized_content (
    id INT PRIMARY KEY AUTO_INCREMENT,
    content_key VARCHAR(255) NOT NULL,
    language CHAR(2) NOT NULL,
    content TEXT NOT NULL,
    content_type ENUM('text', 'html', 'markdown') DEFAULT 'text',
    active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    UNIQUE KEY unique_content_lang (content_key, language),
    INDEX idx_content_key (content_key),
    INDEX idx_language (language)
);

-- Pages with localization
CREATE TABLE pages (
    id INT PRIMARY KEY AUTO_INCREMENT,
    slug VARCHAR(255) NOT NULL UNIQUE,
    template VARCHAR(100) NOT NULL,
    active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE page_localizations (
    id INT PRIMARY KEY AUTO_INCREMENT,
    page_id INT NOT NULL,
    language CHAR(2) NOT NULL,
    title VARCHAR(255) NOT NULL,
    content LONGTEXT,
    meta_description TEXT,
    meta_keywords TEXT,
    custom_data JSON,

    FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE,
    UNIQUE KEY unique_page_lang (page_id, language)
);

-- Products with localization and regional pricing
CREATE TABLE products (
    id INT PRIMARY KEY AUTO_INCREMENT,
    sku VARCHAR(100) NOT NULL UNIQUE,
    slug VARCHAR(255) NOT NULL,
    category_id INT,
    base_price DECIMAL(10,2) NOT NULL,
    sort_order INT DEFAULT 0,
    active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE product_localizations (
    id INT PRIMARY KEY AUTO_INCREMENT,
    product_id INT NOT NULL,
    language CHAR(2) NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    short_description TEXT,
    specifications JSON,

    FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
    UNIQUE KEY unique_product_lang (product_id, language)
);

CREATE TABLE product_regional_pricing (
    id INT PRIMARY KEY AUTO_INCREMENT,
    product_id INT NOT NULL,
    country CHAR(2) NOT NULL,
    currency CHAR(3) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    tax_rate DECIMAL(5,4) DEFAULT 0,

    FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
    UNIQUE KEY unique_product_country (product_id, country)
);

-- Product availability by country
CREATE TABLE product_availability (
    id INT PRIMARY KEY AUTO_INCREMENT,
    product_id INT NOT NULL,
    country CHAR(2) NOT NULL,
    available BOOLEAN DEFAULT TRUE,
    restriction_reason VARCHAR(255),

    FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
    UNIQUE KEY unique_product_availability (product_id, country)
);

-- Categories with localization
CREATE TABLE categories (
    id INT PRIMARY KEY AUTO_INCREMENT,
    slug VARCHAR(255) NOT NULL UNIQUE,
    parent_id INT NULL,
    sort_order INT DEFAULT 0,
    active BOOLEAN DEFAULT TRUE,

    FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
);

CREATE TABLE category_localizations (
    id INT PRIMARY KEY AUTO_INCREMENT,
    category_id INT NOT NULL,
    language CHAR(2) NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,

    FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
    UNIQUE KEY unique_category_lang (category_id, language)
);

-- Sample data
INSERT INTO localized_content (content_key, language, content) VALUES
('welcome_message', 'en', 'Welcome to our store!'),
('welcome_message', 'es', '¡Bienvenido a nuestra tienda!'),
('welcome_message', 'fr', 'Bienvenue dans notre magasin!'),
('welcome_message', 'de', 'Willkommen in unserem Shop!'),

('add_to_cart', 'en', 'Add to Cart'),
('add_to_cart', 'es', 'Añadir al Carrito'),
('add_to_cart', 'fr', 'Ajouter au Panier'),
('add_to_cart', 'de', 'In den Warenkorb'),

('price_from', 'en', 'From {currency}{price}'),
('price_from', 'es', 'Desde {currency}{price}'),
('price_from', 'fr', 'À partir de {currency}{price}'),
('price_from', 'de', 'Ab {currency}{price}');

SEO Considerations

International SEO Manager

<?php
// src/InternationalSEO.php
class InternationalSEO
{
    private MultilangManager $multilang;
    private LocalizedContentManager $content;
    private string $baseUrl;

    public function __construct(MultilangManager $multilang, LocalizedContentManager $content, string $baseUrl)
    {
        $this->multilang = $multilang;
        $this->content = $content;
        $this->baseUrl = rtrim($baseUrl, '/');
    }

    public function generateHreflangTags(string $path = ''): string
    {
        $tags = [];
        $languages = $this->multilang->getSupportedLanguages();
        $currentPath = $path ?: $_SERVER['REQUEST_URI'] ?? '/';

        foreach ($languages as $lang) {
            $url = $this->generateLanguageUrl($currentPath, $lang);
            $tags[] = "<link rel=\"alternate\" hreflang=\"{$lang}\" href=\"{$url}\" />";
        }

        // Add x-default (canonical)
        $defaultUrl = $this->generateLanguageUrl($currentPath, 'en');
        $tags[] = "<link rel=\"alternate\" hreflang=\"x-default\" href=\"{$defaultUrl}\" />";

        return implode("\n", $tags);
    }

    private function generateLanguageUrl(string $path, string $language): string
    {
        // Remove existing language from path
        $cleanPath = preg_replace('/^\/[a-z]{2}\//', '/', $path);
        return $this->baseUrl . '/' . $language . $cleanPath;
    }

    public function generateLocalizedStructuredData(array $data): string
    {
        $language = $this->multilang->getCurrentLanguage();
        $country = $this->multilang->getCurrentCountry();
        $regionalConfig = $this->multilang->getRegionalConfig();

        // Localize structured data
        if (isset($data['@type']) && $data['@type'] === 'Product') {
            $data['inLanguage'] = $language;

            // Localize offers
            if (isset($data['offers'])) {
                $data['offers']['priceCurrency'] = $regionalConfig['currency'];
                $data['offers']['availableAtOrFrom'] = [
                    '@type' => 'Country',
                    'name' => $country
                ];
            }
        }

        if (isset($data['@type']) && $data['@type'] === 'Organization') {
            $data['address']['addressCountry'] = $country;
            $data['currenciesAccepted'] = $regionalConfig['currency'];
        }

        return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    }

    public function generateLocalizedSitemap(): string
    {
        $languages = $this->multilang->getSupportedLanguages();
        $sitemap = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $sitemap .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">' . "\n";

        // Get all pages
        $pages = $this->getLocalizedPages();

        foreach ($pages as $page) {
            foreach ($languages as $lang) {
                $url = $this->generateLanguageUrl($page['path'], $lang);
                $sitemap .= "  <url>\n";
                $sitemap .= "    <loc>{$url}</loc>\n";

                // Add alternate language links
                foreach ($languages as $altLang) {
                    if ($altLang !== $lang) {
                        $altUrl = $this->generateLanguageUrl($page['path'], $altLang);
                        $sitemap .= "    <xhtml:link rel=\"alternate\" hreflang=\"{$altLang}\" href=\"{$altUrl}\" />\n";
                    }
                }

                $sitemap .= "    <lastmod>{$page['updated_at']}</lastmod>\n";
                $sitemap .= "  </url>\n";
            }
        }

        $sitemap .= '</urlset>';
        return $sitemap;
    }

    private function getLocalizedPages(): array
    {
        // This would typically query your database for all pages
        return [
            ['path' => '/', 'updated_at' => '2025-07-30T12:00:00Z'],
            ['path' => '/products/', 'updated_at' => '2025-07-30T12:00:00Z'],
            ['path' => '/about/', 'updated_at' => '2025-07-30T12:00:00Z'],
            ['path' => '/contact/', 'updated_at' => '2025-07-30T12:00:00Z']
        ];
    }

    public function getLocalizedMetaTags(string $page): array
    {
        $language = $this->multilang->getCurrentLanguage();
        $country = $this->multilang->getCurrentCountry();
        $regionalConfig = $this->multilang->getRegionalConfig();

        $pageData = $this->content->getLocalizedPage($page);

        return [
            'title' => $pageData['title'],
            'description' => $pageData['meta_description'],
            'keywords' => $pageData['meta_keywords'],
            'language' => $language,
            'locale' => $regionalConfig['locale'],
            'currency' => $regionalConfig['currency'],
            'country' => $country,
            'direction' => $regionalConfig['rtl'] ? 'rtl' : 'ltr'
        ];
    }

    public function generateCanonicalUrl(string $path = ''): string
    {
        $currentPath = $path ?: $_SERVER['REQUEST_URI'] ?? '/';
        return $this->generateLanguageUrl($currentPath, 'en'); // Default to English
    }
}

User Preferences

Language Preference Manager

<?php
// src/LanguagePreferenceManager.php
class LanguagePreferenceManager
{
    private MultilangManager $multilang;
    private PDO $db;
    private string $cookieName;
    private int $cookieLifetime;

    public function __construct(MultilangManager $multilang, PDO $db, array $config = [])
    {
        $this->multilang = $multilang;
        $this->db = $db;
        $this->cookieName = $config['cookie_name'] ?? 'user_language_pref';
        $this->cookieLifetime = $config['cookie_lifetime'] ?? (86400 * 365); // 1 year
    }

    public function saveUserPreference(int $userId, string $language, string $country): void
    {
        $stmt = $this->db->prepare("
            INSERT INTO user_language_preferences (user_id, language, country, updated_at)
            VALUES (?, ?, ?, NOW())
            ON DUPLICATE KEY UPDATE
                language = VALUES(language),
                country = VALUES(country),
                updated_at = NOW()
        ");
        $stmt->execute([$userId, $language, $country]);

        // Also save to cookie for guest users
        $this->saveToCookie($language, $country);
    }

    public function getUserPreference(int $userId): ?array
    {
        $stmt = $this->db->prepare("
            SELECT language, country, updated_at
            FROM user_language_preferences
            WHERE user_id = ?
        ");
        $stmt->execute([$userId]);
        $result = $stmt->fetch(PDO::FETCH_ASSOC);

        return $result ?: null;
    }

    public function getGuestPreference(): ?array
    {
        if (!isset($_COOKIE[$this->cookieName])) {
            return null;
        }

        $data = json_decode($_COOKIE[$this->cookieName], true);
        if (!$data || !isset($data['language'], $data['country'])) {
            return null;
        }

        return $data;
    }

    private function saveToCookie(string $language, string $country): void
    {
        $data = json_encode([
            'language' => $language,
            'country' => $country,
            'timestamp' => time()
        ]);

        setcookie(
            $this->cookieName,
            $data,
            time() + $this->cookieLifetime,
            '/',
            '',
            true, // Secure
            true  // HttpOnly
        );
    }

    public function handleLanguageChange(string $newLanguage): array
    {
        $currentCountry = $this->multilang->getCurrentCountry();
        $response = ['success' => false, 'message' => '', 'redirect_url' => ''];

        if (!$this->multilang->isLanguageSupported($newLanguage)) {
            $response['message'] = 'Language not supported';
            return $response;
        }

        // Check if language is available in current country
        $availableLanguages = $this->multilang->getAvailableLanguagesForCountry();
        if (!in_array($newLanguage, $availableLanguages)) {
            $response['message'] = 'Language not available in your region';
            return $response;
        }

        // Set the new language
        $this->multilang->setLanguage($newLanguage);

        // Save preference
        if (isset($_SESSION['user_id'])) {
            $this->saveUserPreference($_SESSION['user_id'], $newLanguage, $currentCountry);
        } else {
            $this->saveToCookie($newLanguage, $currentCountry);
        }

        // Generate redirect URL with new language
        $currentUri = $_SERVER['REQUEST_URI'] ?? '/';
        $response['redirect_url'] = $this->generateLocalizedUrl($currentUri, $newLanguage);
        $response['success'] = true;
        $response['message'] = 'Language changed successfully';

        return $response;
    }

    private function generateLocalizedUrl(string $uri, string $language): string
    {
        // Remove existing language from URL
        $cleanUri = preg_replace('/^\/[a-z]{2}\//', '/', $uri);
        return '/' . $language . $cleanUri;
    }

    public function getLanguageAnalytics(int $days = 30): array
    {
        $stmt = $this->db->prepare("
            SELECT
                language,
                country,
                COUNT(*) as user_count,
                DATE(updated_at) as date
            FROM user_language_preferences
            WHERE updated_at >= DATE_SUB(NOW(), INTERVAL ? DAY)
            GROUP BY language, country, DATE(updated_at)
            ORDER BY date DESC, user_count DESC
        ");
        $stmt->execute([$days]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

// Database table for user preferences
/*
CREATE TABLE user_language_preferences (
    user_id INT PRIMARY KEY,
    language CHAR(2) NOT NULL,
    country CHAR(2) NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    INDEX idx_language (language),
    INDEX idx_country (country),
    INDEX idx_updated (updated_at)
);
*/

// AJAX language switcher example
/*
// JavaScript
function changeLanguage(newLang) {
    fetch('/api/change-language', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-Requested-With': 'XMLHttpRequest'
        },
        body: JSON.stringify({language: newLang})
    })
    .then(response => response.json())
    .then(data => {
        if (data.success) {
            window.location.href = data.redirect_url;
        } else {
            alert(data.message);
        }
    });
}
*/

Next Steps


Previous: Production Best Practices | Next: Geographic Content Delivery

Clone this wiki locally