-
Notifications
You must be signed in to change notification settings - Fork 0
Multilang Websites
Rumen Damyanov edited this page Jul 31, 2025
·
1 revision
Complete guide for building international applications with automatic language detection, content localization, and geographic personalization using php-geolocation.
- Architecture Overview
- Language Detection Strategy
- URL Structure Patterns
- Content Management
- Database Design
- SEO Considerations
- User Preferences
- Translation Workflow
- Performance Optimization
- Testing Multi-language Features
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
<?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;
}
}<?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);<?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;
});<?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();<?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();<?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 {}-- 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}');<?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
}
}<?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);
}
});
}
*/- 📍 Geographic Content Delivery - Location-based content serving
- 📊 Analytics Integration - Geographic analytics and insights
- 🎯 API Development Patterns - RESTful APIs with geolocation
- 🔧 Configuration Reference - Complete configuration options
Previous: Production Best Practices | Next: Geographic Content Delivery