monkeyscloud/monkeyslegion-i18n

Production-ready I18n & localization component for the MonkeysLegion PHP framework — v2

Maintainers

Package info

github.com/MonkeysCloud/MonkeysLegion-I18n

pkg:composer/monkeyscloud/monkeyslegion-i18n

Statistics

Installs: 1 447

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

2.1.1 2026-04-19 04:14 UTC

README

Production-ready internationalization & localization for the MonkeysLegion PHP framework.

PHP 8.4+ Tests License v2 Standards

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

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 &lt;script&gt;alert(1)&lt;/script&gt;"

// 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