monkeyscloud / monkeyslegion-i18n
Production-ready I18n & localization component for the MonkeysLegion PHP framework — v2
Package info
github.com/MonkeysCloud/MonkeysLegion-I18n
pkg:composer/monkeyscloud/monkeyslegion-i18n
Requires
- php: ^8.4
- ext-json: *
- ext-mbstring: *
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/simple-cache: ^3.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.3
Suggests
- ext-intl: For advanced number, currency, and date formatting (optional)
- monkeyscloud/monkeyslegion-cache: For cache-backed translation loader
- monkeyscloud/monkeyslegion-database: For database translation loader
- monkeyscloud/monkeyslegion-template: For template directive support
This package is auto-updated.
Last update: 2026-04-19 04:15:38 UTC
README
Production-ready internationalization & localization for the MonkeysLegion PHP framework.
Translate, pluralize, format numbers/dates/currencies, and manage locales with security and performance at the core. Built with PHP 8.4 property hooks and asymmetric visibility.
Table of Contents
- Installation
- Quick Start
- Translator
- MessageFormatter
- NumberFormatter
- DateFormatter
- LocaleManager
- LocaleInfo
- Loaders
- Middleware
- Enums
- Attributes
- Events
- Template Directives
- Helper Functions
- TranslatorFactory
- Translation Management
- CLI Commands
- Security
- Performance
- Migration from v1
- Testing
- License
Installation
composer require monkeyscloud/monkeyslegion-i18n
Requirements
- PHP 8.4+ (property hooks, asymmetric visibility)
- ext-json (JSON translation files)
- ext-mbstring (Unicode support)
Optional Extensions
# Advanced number/currency/date formatting ext-intl # For database translations monkeyscloud/monkeyslegion-database # For cache-backed loading monkeyscloud/monkeyslegion-cache
Quick Start
use MonkeysLegion\I18n\TranslatorFactory; // One-line setup $translator = TranslatorFactory::create([ 'locale' => 'en', 'fallback' => 'en', 'path' => __DIR__ . '/resources/lang', ]); echo $translator->trans('messages.welcome'); // → "Welcome!" echo $translator->trans('messages.greeting', ['name' => 'Yorch']); // → "Hello, Yorch!" echo $translator->choice('messages.items', 5); // → "5 items"
Translation File Structure
resources/lang/
├── en/
│ ├── messages.json
│ └── validation.json
├── es/
│ ├── messages.json
│ └── validation.json
└── fr/
└── messages.json
Example messages.json
{
"welcome": "Welcome!",
"greeting": "Hello, :name!",
"farewell": "Goodbye, :NAME!",
"items": "{0} No items|{1} One item|[2,*] :count items",
"nested": {
"key": "Nested value",
"deep": {
"value": "Deep nested value"
}
}
}
Translator
Basic Translation
use MonkeysLegion\I18n\Translator; use MonkeysLegion\I18n\Loaders\FileLoader; $translator = new Translator('en', 'en'); $translator->addLoader(new FileLoader('/path/to/lang')); // Simple key echo $translator->trans('messages.welcome'); // → "Welcome!" // Nested key echo $translator->trans('messages.nested.deep.value'); // → "Deep nested value" // Check if translation exists if ($translator->has('messages.welcome')) { // Key exists } // Returns the key itself when not found echo $translator->trans('messages.nonexistent'); // → "messages.nonexistent"
Parameter Replacement
// Lowercase :name → exact value echo $translator->trans('messages.greeting', ['name' => 'Yorch']); // → "Hello, Yorch!" // Uppercase :NAME → UPPERCASED echo $translator->trans('messages.farewell', ['name' => 'Yorch']); // → "Goodbye, YORCH!" // Ucfirst :Name → Capitalized // Message: "Welcome :Name" echo $translator->trans('messages.title', ['name' => 'yorch']); // → "Welcome Yorch" // Multiple parameters echo $translator->trans('order.summary', [ 'product' => 'Widget', 'count' => 3, 'total' => '29.97', ]); // → "3x Widget — Total: $29.97"
Pluralization
Supports ICU plural categories for 200+ languages.
// Explicit count forms // "{0} No items|{1} One item|[2,*] :count items" echo $translator->choice('messages.items', 0); // → "No items" echo $translator->choice('messages.items', 1); // → "One item" echo $translator->choice('messages.items', 42); // → "42 items" // Range forms // "[0,3] A few|[4,10] Several|[11,*] Many" echo $translator->choice('messages.range', 2); // → "A few" echo $translator->choice('messages.range', 7); // → "Several" echo $translator->choice('messages.range', 50); // → "Many" // Simple pipe-delimited (singular|plural) // "apple|apples" echo $translator->choice('messages.fruit', 1); // → "apple" echo $translator->choice('messages.fruit', 5); // → "apples" // With additional replacements echo $translator->choice('messages.items', 3, ['color' => 'red']); // "{0} No :color items|{1} One :color item|[2,*] :count :color items" // → "3 red items"
Namespaced Translations
// Register a namespace $translator->addNamespace('billing', '/path/to/billing/lang'); $translator->addLoader(new FileLoader('/path/to/billing/lang')); // Use namespace::group.key format echo $translator->trans('billing::invoices.title'); // → Loads from /path/to/billing/lang/{locale}/invoices.json
Fallback Locale
$translator = new Translator('fr', 'en'); // If 'fr' translation missing, falls back to 'en' echo $translator->trans('messages.welcome'); // French translation exists → "Bienvenue!" echo $translator->trans('messages.rare_key'); // French missing, English fallback → "Rare English Value"
Missing Translation Tracking
$translator->setTrackMissing(true); $translator->trans('messages.missing1'); $translator->trans('messages.missing2'); $missing = $translator->getMissingTranslations(); // ["en.messages.missing1", "en.messages.missing2"] $translator->clearMissingTranslations();
Warm-Up
Pre-load translation groups for production performance:
$translator->warmUp('en', ['messages', 'validation', 'auth']); // Check what's loaded $groups = $translator->getLoadedGroups(); // ["messages.en", "validation.en", "auth.en"]
Property Hooks (PHP 8.4)
$translator = new Translator('en'); // Use property directly echo $translator->locale; // → "en" // Setter triggers validation + event dispatch $translator->locale = 'es'; // Invalid locale throws InvalidLocaleException $translator->locale = '../etc/passwd'; // ❌ InvalidLocaleException
Locale Changed Events
$translator->setEventDispatcher(function (LocaleChangedEvent $event): void { log("Locale changed: {$event->previousLocale} → {$event->newLocale}"); }); $translator->setLocale('fr'); // Event fires automatically
MessageFormatter
Parameter Modifiers
Use braced syntax with modifiers for advanced formatting:
use MonkeysLegion\I18n\MessageFormatter; $formatter = new MessageFormatter(); // Uppercase echo $formatter->format('Name: {name|upper}', ['name' => 'yorch']); // → "Name: YORCH" // Lowercase echo $formatter->format('Email: {email|lower}', ['email' => 'USER@EXAMPLE.COM']); // → "Email: user@example.com" // Title case echo $formatter->format('Title: {title|title}', ['title' => 'hello world']); // → "Title: Hello World" // Capitalize first echo $formatter->format('{msg|ucfirst}', ['msg' => 'hello']); // → "Hello" // Truncate echo $formatter->format('{desc|truncate:20}', ['desc' => 'Very long description text']); // → "Very long descriptio..." // Number formatting echo $formatter->format('Total: {amount|number:2}', ['amount' => 1234.5]); // → "Total: 1,234.50" // Currency echo $formatter->format('Price: {price|currency:EUR}', ['price' => 42.50]); // → "Price: €42.50" // Percentage echo $formatter->format('Rate: {rate|percent}', ['rate' => 0.156]); // → "Rate: 15.60%" // Date formatting echo $formatter->format('Date: {date|date:medium}', ['date' => '2026-01-15']); // → "Date: Jan 15, 2026" // Default fallback echo $formatter->format('Name: {name|default:Anonymous}', ['name' => '']); // → "Name: Anonymous"
XSS Protection
// Auto-escape disabled by default for backward compatibility $formatter = new MessageFormatter(); echo $formatter->format('Hello :name', ['name' => '<script>alert(1)</script>']); // → "Hello <script>alert(1)</script>" // Enable auto-escape for user-facing output $safe = new MessageFormatter(autoEscape: true); echo $safe->format('Hello :name', ['name' => '<script>alert(1)</script>']); // → "Hello <script>alert(1)</script>" // Custom sanitizer $custom = new MessageFormatter( autoEscape: true, sanitizer: new MyCustomSanitizer(), );
NumberFormatter
Locale-aware number formatting with graceful ext-intl fallback.
use MonkeysLegion\I18n\NumberFormatter; $nf = new NumberFormatter();
Decimal Formatting
echo $nf->decimal(1234567, 'en'); // → "1,234,567" echo $nf->decimal(1234567, 'de'); // → "1.234.567" (with ext-intl) echo $nf->decimal(3.14159, 'en', 2); // → "3.14"
Currency Formatting
echo $nf->currency(42.50, 'USD', 'en'); // → "$42.50" echo $nf->currency(42.50, 'EUR', 'de'); // → "42,50 €" (with ext-intl) echo $nf->currency(42.50, 'GBP', 'en'); // → "£42.50" echo $nf->currency(42.50, 'JPY', 'ja'); // → "¥42.50" echo $nf->currency(42.50, 'BRL', 'pt'); // → "R$42.50" // 32 built-in currency symbols // USD, EUR, GBP, JPY, CAD, AUD, CHF, CNY, MXN, BRL, // INR, KRW, RUB, TRY, SEK, NOK, DKK, PLN, CZK, HUF, // RON, BGN, HRK, THB, PHP, MYR, IDR, VND, ZAR, EGP, // NGN, KES, ARS, CLP, COP, PEN
Compact Notation
echo $nf->compact(500); // → "500" echo $nf->compact(1_234); // → "1.2K" echo $nf->compact(1_500_000); // → "1.5M" echo $nf->compact(2_345_000_000); // → "2.3B" echo $nf->compact(-1_234); // → "-1.2K"
Ordinals
echo $nf->ordinal(1); // → "1st" echo $nf->ordinal(2); // → "2nd" echo $nf->ordinal(3); // → "3rd" echo $nf->ordinal(11); // → "11th" echo $nf->ordinal(21); // → "21st" echo $nf->ordinal(112); // → "112th"
Percentage
echo $nf->percent(0.156, 'en', 1); // → "15.6%" echo $nf->percent(0.5, 'en'); // → "50%"
File Size
echo $nf->fileSize(0); // → "0 B" echo $nf->fileSize(1024); // → "1.00 KB" echo $nf->fileSize(1_572_864); // → "1.50 MB" echo $nf->fileSize(5e9); // → "4.66 GB"
Spell Out
echo $nf->spellOut(123, 'en'); // → "one hundred twenty-three" (requires ext-intl) echo $nf->spellOut(42, 'es'); // → "cuarenta y dos" (requires ext-intl)
DateFormatter
Locale-aware date/time formatting with relative time support.
use MonkeysLegion\I18n\DateFormatter; $df = new DateFormatter();
Named Formats
$date = new DateTimeImmutable('2026-01-15'); echo $df->format($date, 'short'); // → "1/15/26" echo $df->format($date, 'medium'); // → "Jan 15, 2026" echo $df->format($date, 'long'); // → "January 15, 2026" echo $df->format($date, 'full'); // → "Thursday, January 15, 2026" echo $df->format($date, 'iso'); // → "2026-01-15" echo $df->format($date, 'time'); // → "12:00 AM" echo $df->format($date, 'datetime'); // → "Jan 15, 2026 12:00 AM" // Custom format echo $df->format($date, 'Y/m/d'); // → "2026/01/15" // From timestamp echo $df->format(1705276800, 'medium'); // → "Jan 15, 2024" // From string echo $df->format('2026-01-15', 'long'); // → "January 15, 2026" // With timezone echo $df->format($date, 'datetime', 'en', 'America/New_York');
Relative Time
$now = new DateTimeImmutable('2026-01-15 12:00:00'); // Past — English echo $df->relative('2026-01-15 11:58:00', 'en', $now); // → "2 minutes ago" echo $df->relative('2026-01-15 10:00:00', 'en', $now); // → "2 hours ago" echo $df->relative('2026-01-14 12:00:00', 'en', $now); // → "1 day ago" echo $df->relative('2025-11-15 12:00:00', 'en', $now); // → "2 months ago" // Past — Spanish echo $df->relative('2026-01-15 10:00:00', 'es', $now); // → "hace 2 horas" echo $df->relative('2026-01-14 12:00:00', 'es', $now); // → "hace 1 día" // Future echo $df->relative('2026-01-15 14:00:00', 'en', $now); // → "in 2 hours" // Just now (< 10 seconds) echo $df->relative('2026-01-15 11:59:55', 'en', $now); // → "just now"
Diff for Humans
echo $df->diffForHumans( '2026-01-15 10:00:00', '2026-01-15 12:30:00', 'en', ); // → "2 hours ago"
Day and Month Names
$date = new DateTimeImmutable('2026-01-15'); echo $df->dayOfWeek($date); // → "Thursday" echo $df->dayOfWeek($date, short: true); // → "Thu" echo $df->monthName($date); // → "January" echo $df->monthName($date, short: true); // → "Jan"
ISO 8601
echo $df->iso('2026-01-15 12:00:00'); // → "2026-01-15T12:00:00+00:00"
LocaleManager
Manages locale detection, validation, and state.
use MonkeysLegion\I18n\LocaleManager; $manager = new LocaleManager( defaultLocale: 'en', supportedLocales: ['en', 'es', 'fr', 'de', 'ja'], fallbackLocale: 'en', );
Detection Chain
use MonkeysLegion\I18n\Detectors\{ UrlDetector, QueryDetector, SessionDetector, CookieDetector, HeaderDetector, SubdomainDetector, }; // Priority order (first match wins) $manager->addDetector(new UrlDetector(segment: 0)); // /es/products $manager->addDetector(new QueryDetector(paramName: 'lang')); // ?lang=es $manager->addDetector(new SessionDetector(key: 'locale')); $manager->addDetector(new CookieDetector(cookieName: 'locale')); $manager->addDetector(new HeaderDetector()); // Accept-Language $manager->addDetector(new SubdomainDetector()); // es.example.com $locale = $manager->detectLocale(); // Tries each detector in order, returns first supported match
Supported Locales
$manager->isSupported('es'); // → true $manager->isSupported('xx'); // → false $manager->addSupportedLocale('pt'); $manager->isSupported('pt'); // → true $manager->setLocale('es'); // ✅ Switches locale $manager->setLocale('xx'); // ❌ throws InvalidArgumentException // Asymmetric visibility (PHP 8.4) echo $manager->defaultLocale; // → "en" (read-only from outside) echo $manager->fallbackLocale; // → "en" print_r($manager->supportedLocales); // → ["en", "es", "fr", "de", "ja"]
Locale Parsing
echo $manager->parseLocale('en-US'); // → "en" echo $manager->parseLocale('en_US'); // → "en" echo $manager->parseLocale('pt-BR'); // → "pt" echo $manager->parseLocale('es'); // → "es"
LocaleInfo
Static metadata for 50+ locales.
use MonkeysLegion\I18n\Support\LocaleInfo;
Native Names
echo LocaleInfo::name('es'); // → "Spanish" echo LocaleInfo::nativeName('es'); // → "Español" echo LocaleInfo::nativeName('ja'); // → "日本語" echo LocaleInfo::nativeName('ar'); // → "العربية" echo LocaleInfo::nativeName('ru'); // → "Русский" echo LocaleInfo::nativeName('hi'); // → "हिन्दी" echo LocaleInfo::nativeName('ko'); // → "한국어" echo LocaleInfo::nativeName('zh'); // → "中文"
RTL Detection
echo LocaleInfo::isRtl('ar'); // → true (Arabic) echo LocaleInfo::isRtl('he'); // → true (Hebrew) echo LocaleInfo::isRtl('fa'); // → true (Persian) echo LocaleInfo::isRtl('ur'); // → true (Urdu) echo LocaleInfo::isRtl('en'); // → false echo LocaleInfo::direction('ar'); // → Direction::RTL echo LocaleInfo::direction('en'); // → Direction::LTR
Flag Emojis
echo LocaleInfo::flag('en'); // → 🇺🇸 echo LocaleInfo::flag('es'); // → 🇪🇸 echo LocaleInfo::flag('fr'); // → 🇫🇷 echo LocaleInfo::flag('jp'); // → 🇯🇵 echo LocaleInfo::flag('br'); // → 🇧🇷
Script & Knowledge
echo LocaleInfo::script('en'); // → "Latn" echo LocaleInfo::script('ar'); // → "Arab" echo LocaleInfo::script('ru'); // → "Cyrl" echo LocaleInfo::script('ja'); // → "Jpan" echo LocaleInfo::script('ko'); // → "Kore" echo LocaleInfo::isKnown('en'); // → true echo LocaleInfo::isKnown('xx'); // → false $allCodes = LocaleInfo::allCodes(); // 50+ locale codes
Loaders
FileLoader
Loads from JSON and PHP files with security hardening.
use MonkeysLegion\I18n\Loaders\FileLoader; $loader = new FileLoader('/path/to/lang'); // Loads from /path/to/lang/{locale}/{group}.json $messages = $loader->load('en', 'messages'); // Add namespace path $loader->addNamespace('billing', '/path/to/billing/lang'); // Security features: // ✅ Path traversal prevention (realpath validation) // ✅ Null byte injection prevention // ✅ Max file size limit (2MB) // ✅ JSON_THROW_ON_ERROR on all json_decode // ✅ Symlink resolution and validation
DatabaseLoader
Load translations from any SQL database — MySQL, MariaDB, PostgreSQL, and SQLite.
Install the Schema
Ready-to-use SQL files are included in schema/:
# MySQL / MariaDB mysql -u root -p your_database < schema/mysql.sql # PostgreSQL psql -U postgres -d your_database -f schema/pgsql.sql # SQLite sqlite3 storage/database.sqlite < schema/sqlite.sql
Schema (MySQL)
CREATE TABLE IF NOT EXISTS translations ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, locale VARCHAR(10) NOT NULL, `group` VARCHAR(50) NOT NULL, namespace VARCHAR(50) NOT NULL DEFAULT '', `key` VARCHAR(255) NOT NULL, value TEXT NOT NULL, source VARCHAR(50) NOT NULL DEFAULT 'file', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE INDEX idx_translation_unique (locale, `group`, namespace, `key`), INDEX idx_translation_locale (locale), INDEX idx_translation_group (locale, `group`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Schema (PostgreSQL)
CREATE TABLE IF NOT EXISTS translations ( id BIGSERIAL PRIMARY KEY, locale VARCHAR(10) NOT NULL, "group" VARCHAR(50) NOT NULL, namespace VARCHAR(50) NOT NULL DEFAULT '', "key" VARCHAR(255) NOT NULL, value TEXT NOT NULL, source VARCHAR(50) NOT NULL DEFAULT 'file', created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE (locale, "group", namespace, "key") );
Schema (SQLite)
CREATE TABLE IF NOT EXISTS translations ( id INTEGER PRIMARY KEY AUTOINCREMENT, locale TEXT NOT NULL, "group" TEXT NOT NULL, namespace TEXT NOT NULL DEFAULT '', "key" TEXT NOT NULL, value TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'file', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (locale, "group", namespace, "key") );
Usage
use MonkeysLegion\I18n\Loaders\DatabaseLoader; // Works with any PDO connection — auto-detects driver $loader = new DatabaseLoader($pdo, 'translations'); $messages = $loader->load('en', 'messages'); // Stack with FileLoader (DB translations override file translations) $translator = new Translator('en', 'en'); $translator->addLoader(new FileLoader('/path/to/lang')); // Base $translator->addLoader($loader); // Override // Or use TranslatorFactory (recommended) $translator = TranslatorFactory::create([ 'path' => '/path/to/lang', 'pdo' => $pdo, // Enables DatabaseLoader automatically ]); // Security features: // ✅ Table name validated against regex pattern // ✅ All queries use parameterized statements // ✅ Cross-database UPSERT (ON CONFLICT / ON DUPLICATE KEY) // ✅ Readonly PDO property
CacheLoader
Decorator that caches translations from any other loader.
use MonkeysLegion\I18n\Loaders\CacheLoader; $cached = new CacheLoader( loader: $fileLoader, cache: $psr16Cache, ttl: 3600, prefix: 'i18n', ); // Features: // ✅ TTL jitter (±10%) to prevent thundering herd // ✅ Selective cache invalidation // ✅ Flush all cached translations $cached->forget('en', 'messages'); // Clear specific group $cached->flush(); // Clear all
CompiledLoader
Opcache-friendly compiled PHP files — 10-50x faster than JSON decode.
use MonkeysLegion\I18n\Loaders\CompiledLoader; $compiled = new CompiledLoader( sourceLoader: $fileLoader, compilePath: '/var/cache/i18n', ); // Compile all translations for a locale $compiled->compile('en', '/path/to/lang'); // → Creates /var/cache/i18n/en.compiled.php // Check if compiled cache is fresh if (!$compiled->isFresh('en', '/path/to/lang')) { $compiled->compile('en', '/path/to/lang'); } // Invalidate $compiled->invalidate('en'); // Atomic writes via temp file + rename // Auto opcache invalidation
MlcLoader
Zero-dependency alternative to YAML — uses a simple key=value format.
use MonkeysLegion\I18n\Loaders\MlcLoader; $loader = new MlcLoader('/path/to/lang'); $messages = $loader->load('en', 'messages'); // Loads from /path/to/lang/en/messages.mlc
MLC file format (flat — one group per file):
# resources/lang/en/messages.mlc # Comments start with # or ; welcome = Welcome! greeting = Hello, :name! farewell = "Goodbye, :NAME!" # Dot notation creates nested arrays nested.key = Nested value nested.deep.value = Deep nested value # Escape sequences in double-quoted values multiline = "Line one\nLine two"
MLC file format (sectioned — multiple groups per file):
# resources/lang/es.mlc [messages] welcome = ¡Bienvenido! greeting = ¡Hola, :name! [validation] required = El campo :field es obligatorio. email = Ingrese un correo electrónico válido.
Why MLC over YAML?
- Zero external dependencies (no
symfony/yaml) - Simpler syntax, less error-prone
- Translators don't need to learn YAML indentation rules
- Same security hardening as FileLoader
Middleware
LocaleMiddleware
Auto-detect and set locale for each request.
use MonkeysLegion\I18n\Middleware\LocaleMiddleware; $middleware = new LocaleMiddleware( manager: $localeManager, translator: $translator, setSession: true, setCookie: true, cookieTtl: 31536000, // 1 year ); // Security features: // ✅ SameSite=Lax on cookies // ✅ HttpOnly flag // ✅ Secure flag when HTTPS // ✅ headers_sent() guard
LocaleUrlMiddleware
Extract locale from URL path segment.
use MonkeysLegion\I18n\Middleware\LocaleUrlMiddleware; // /es/products → sets locale to "es" $middleware = new LocaleUrlMiddleware($manager, $translator, segment: 0);
LocaleRedirectMiddleware
Auto-redirect to localized URL if locale prefix is missing.
use MonkeysLegion\I18n\Middleware\LocaleRedirectMiddleware; // /products → 302 → /en/products $middleware = new LocaleRedirectMiddleware($manager, segment: 0); // No exit() call — returns redirect response array // ✅ headers_sent() guard
Enums
PluralCategory
use MonkeysLegion\I18n\Enum\PluralCategory; // ICU plural categories PluralCategory::Zero; // "zero" PluralCategory::One; // "one" PluralCategory::Two; // "two" PluralCategory::Few; // "few" PluralCategory::Many; // "many" PluralCategory::Other; // "other" PluralCategory::Other->isDefault(); // → true PluralCategory::ordered(); // Ordered list // Get plural category for a count in a locale $pluralizer = new Pluralizer(); $cat = $pluralizer->getCategoryForCount(5, 'en'); // → PluralCategory::Other $cat = $pluralizer->getCategoryForCount(2, 'ar'); // → PluralCategory::Two
Direction
use MonkeysLegion\I18n\Enum\Direction; Direction::LTR; // "ltr" Direction::RTL; // "rtl" Direction::fromLocale('en'); // → Direction::LTR Direction::fromLocale('ar'); // → Direction::RTL Direction::fromLocale('he'); // → Direction::RTL Direction::fromLocale('fa'); // → Direction::RTL Direction::RTL->cssAttribute(); // → 'dir="rtl"'
Attributes
#[Translatable]
Mark entity properties for automatic translation:
use MonkeysLegion\I18n\Attribute\Translatable; class Product { #[Translatable(group: 'products', keyPrefix: 'title')] public string $title; #[Translatable(group: 'products', keyPrefix: 'description', fallbackToValue: true)] public string $description; }
#[Locale]
Auto-inject the detected locale into controller parameters:
use MonkeysLegion\I18n\Attribute\Locale; class ProductController { public function index(#[Locale] string $locale): Response { // $locale is auto-populated from the detected locale } }
Events
use MonkeysLegion\I18n\Event\LocaleChangedEvent; // Immutable event (readonly class) $translator->setEventDispatcher(function (LocaleChangedEvent $event): void { echo $event->previousLocale; // → "en" echo $event->newLocale; // → "es" // Update user preferences, reconfigure formatters, etc. });
Template Directives
For use with MonkeysLegion-Template engine:
use MonkeysLegion\I18n\Template\I18nDirectives; $directives = new I18nDirectives($translator); // Register all directives foreach ($directives->getDirectives() as $name => $handler) { $engine->directive($name, $handler); }
In templates:
{{-- Translation --}} @lang('welcome.message') @lang('welcome.user', ['name' => $user->name]) {{-- Pluralization --}} @choice('messages.count', $count) {{-- Current locale --}} @locale {{-- Formatting --}} @date($order->created_at, 'long') @currency($product->price, 'USD') @number($total, 2)
Helper Functions
Global helper functions for convenience:
// Translation echo trans('messages.welcome'); echo trans('messages.greeting', ['name' => 'Yorch']); // Shorthand alias (__) echo __('messages.welcome'); echo __('messages.greeting', ['name' => 'Yorch']); // Pluralization echo trans_choice('messages.items', 5); echo trans_choice('messages.items', 1, ['color' => 'red']); // Get/set locale echo lang(); // → "en" lang('es'); // Sets locale to "es" echo lang(); // → "es"
TranslatorFactory
One-line creation with all features:
use MonkeysLegion\I18n\TranslatorFactory; // Basic $translator = TranslatorFactory::create([ 'locale' => 'en', 'fallback' => 'en', 'path' => '/path/to/lang', ]); // With caching $translator = TranslatorFactory::create([ 'locale' => 'en', 'path' => '/path/to/lang', 'cache' => $psr16Cache, 'cache_ttl' => 3600, ]); // With compiled loader (production) $translator = TranslatorFactory::create([ 'locale' => 'en', 'path' => '/path/to/lang', 'compiled_path' => '/var/cache/i18n', ]); // With database $translator = TranslatorFactory::create([ 'locale' => 'en', 'path' => '/path/to/lang', 'pdo' => $pdo, 'cache' => $cache, ]); // With namespaces $translator = TranslatorFactory::create([ 'locale' => 'en', 'path' => '/path/to/lang', 'namespaces' => [ 'billing' => '/path/to/billing/lang', 'email' => '/path/to/email/lang', ], ]); // Full system (translator + manager) ['translator' => $t, 'manager' => $m] = TranslatorFactory::createSystem([ 'default' => 'en', 'supported' => ['en', 'es', 'fr'], 'path' => '/path/to/lang', 'detectors' => ['url', 'session', 'cookie', 'header'], ]); // Number & Date formatters $nf = TranslatorFactory::createNumberFormatter(); $df = TranslatorFactory::createDateFormatter();
Translation Management
Full CRUD management for file and database translations:
use MonkeysLegion\I18n\Management\TranslationManager; $manager = new TranslationManager($pdo, $translator, '/path/to/lang'); // CRUD $manager->set('en', 'messages', 'welcome', 'Hello!'); echo $manager->get('en', 'messages', 'welcome'); // → "Hello!" $manager->delete('en', 'messages', 'welcome'); // Import/Export $manager->importFromFile('en', 'messages'); $manager->exportToFile('en', 'messages', 'json'); $manager->importArray('en', 'messages', ['key' => 'value'], overwrite: true); // Sync $manager->sync('en', 'messages', 'file_to_db'); $manager->sync('en', 'messages', 'db_to_file'); // Merged (DB overrides file) $all = $manager->getAllMerged('en', 'messages'); // Search $results = $manager->search('welcome', locale: 'en'); // Statistics $stats = $manager->getStats(); // ['total' => 150, 'by_locale' => [...], 'by_group' => [...], 'by_source' => [...]] // Batch update $manager->batchUpdate([ ['locale' => 'en', 'group' => 'messages', 'key' => 'welcome', 'value' => 'Hi!'], ['locale' => 'en', 'group' => 'messages', 'key' => 'goodbye', 'value' => 'Bye!'], ]); // Find missing (file keys not in DB) $missing = $manager->findMissing('en', 'messages');
CLI Commands
use MonkeysLegion\I18n\Console\TranslationCommand; $cmd = new TranslationCommand($translator, '/path/to/lang'); // Extract translation keys from source code $cmd->extract('/path/to/src', '/path/to/output.json'); // Scans for trans(), __(), @lang(), @choice() calls // Find missing translations $cmd->missing('es'); // ✗ Found 5 missing translations: // - messages.new_feature // - validation.custom_rule // Compare two locales $cmd->compare('en', 'es'); // Missing in es (3): // - messages.new_key // - validation.rule // Export translations $cmd->export('en', 'json', '/path/to/export.json'); $cmd->export('en', 'csv', '/path/to/export.csv'); $cmd->export('en', 'php', '/path/to/export.php');
Security
Path Traversal Prevention
// FileLoader validates all path segments $loader->load('../etc', 'passwd'); // ❌ LoaderException $loader->load("en\0", 'messages'); // ❌ LoaderException $loader->load('en/../../', 'msg'); // ❌ LoaderException
Locale Injection Prevention
// Translator validates locale format: /^[a-z]{2,3}(_[A-Z]{2})?$/ new Translator('../etc/passwd'); // ❌ InvalidLocaleException new Translator("en\0"); // ❌ InvalidLocaleException $translator->locale = '<script>'; // ❌ InvalidLocaleException
SQL Injection Prevention
// DatabaseLoader validates table names new DatabaseLoader($pdo, 'DROP TABLE users; --'); // ❌ InvalidArgumentException // All queries use parameterized statements
XSS Protection
// Enable auto-escaping in MessageFormatter $formatter = new MessageFormatter(autoEscape: true); // All :param replacements are HTML-escaped via htmlspecialchars()
Cookie Security
// LocaleMiddleware sets secure cookie flags // SameSite=Lax, HttpOnly, Secure (when HTTPS)
Performance
Compiled Loader (Production)
// 10-50x faster than JSON decode per request $compiled = new CompiledLoader($fileLoader, '/var/cache/i18n'); $compiled->compile('en', '/path/to/lang'); // Uses PHP's opcache for near-zero overhead // Atomic writes (temp file + rename) // Auto mtime-based freshness checks
Cache with Jitter
// TTL jitter prevents thundering herd (cache stampede) // ±10% variation: TTL 3600 → random 3240-3960 $cached = new CacheLoader($loader, $cache, ttl: 3600);
Warm-Up
// Pre-load all groups at boot time $translator->warmUp('en', ['messages', 'validation', 'auth', 'errors']);
Const Array Pluralizer
// Locale-to-rule mapping is a const array (PHP 8.4) // Zero overhead — compiled into opcache private const array LOCALE_RULES = [ 'pl' => 'polish', 'ru' => 'russian', // ... ];
Migration from v1
Namespace Changes
- use MonkeysLegion\I18n\Contracts\LoaderInterface; + use MonkeysLegion\I18n\Contract\LoaderInterface;
Note: Backward-compatible aliases exist at
src/Contracts/aliases.php.
Property Hooks
- $translator->getLocale(); + $translator->locale; // Read via property + $translator->getLocale(); // Still works (BC) - $translator->setLocale('es'); + $translator->locale = 'es'; // Write via property hook + $translator->setLocale('es'); // Still works (BC)
Locale Validation
// v1: No validation
$translator = new Translator('../etc/passwd');
// v2: Strict validation
+ $translator = new Translator('../etc/passwd');
+ // ❌ InvalidLocaleException
Middleware Split
// v1: All middleware in single file - // src/Middleware/LocaleMiddleware.php contained 3 classes // v2: One class per file + src/Middleware/LocaleMiddleware.php + src/Middleware/LocaleUrlMiddleware.php + src/Middleware/LocaleRedirectMiddleware.php
Testing
# Run all tests composer test # Run with testdox output vendor/bin/phpunit --testdox # Run specific test file vendor/bin/phpunit tests/Unit/I18nV2Test.php # Run specific test vendor/bin/phpunit --filter="translator_translates_basic_key"
Test Coverage
- 139 tests, 250+ assertions
- Covers: Translator, Pluralizer, MessageFormatter, NumberFormatter, DateFormatter, LocaleManager, LocaleInfo, FileLoader, MlcLoader, CompiledLoader, Enums, Attributes, Events, Factory, CLI
License
MIT License. See LICENSE for details.
Built with ❤️ by MonkeysCloud