wufr / php-language-localizer
Translate keys into text values based on set language
Requires
- php: ^8.1
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0
Suggests
- ext-intl: Locale-aware {value, number} and {value, date} placeholders
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-25 13:51:55 UTC
README
A small, dependency-free PHP library for translating and localizing applications: plain PHP locale files, parameter replacement with HTML escaping, CLDR plural rules, gender variants, fallback languages and layered locale directories.
Features
- Plain PHP locale files organized in any folder hierarchy, loaded once per request
- Parameter replacement with HTML escaping by default and explicit opt-outs for trusted markup
- Pluralization with Unicode CLDR categories (
one,few,many, …) for 60+ languages, or simple numeric thresholds - Gender variants, also combined with plurals
- Number and date formatting per locale (with
ext-intl) - Fallback languages and layered locale directories (override single keys of a base set)
- Safe by design: file and language names are validated, so user input can never load files outside the locale directories
- Configurable handling of missing translations: return the key, log it or throw
Installation
composer require wufr/php-language-localizer
Requirements: PHP 8.1 or higher. ext-intl is optional and only used for locale-aware number and date placeholders.
Upgrading from 1.x? See UPGRADE.md.
Basic Usage
use wUFr\Translator; $translator = new Translator( dir: __DIR__ . '/locales/', // Directory containing the language directories lang: 'en_US' // Language directory to read ); echo $translator->locale('common/general', 'welcome'); // Welcome to our application
Setters are chainable:
$translator ->setDirectory(__DIR__ . '/locales/') ->setLanguage('cs_CZ') ->setFallbackLanguages('en_US');
Locale files
locales/
en_US/
common/
general.php
errors.php
admin/
dashboard.php
cs_CZ/
common/
general.php
...
A locale file returns an array (or assigns it to $l, the format used by 1.x):
<?php // locales/en_US/common/general.php return [ 'welcome' => 'Welcome to our application', 'greeting' => 'Hello, {name}!', 'logout' => 'Log out', ];
Locale files are PHP code and are fully trusted: only load files you ship yourself. Each file runs in an isolated scope and is loaded once per Translator instance.
Names. A file name is one or more segments separated by /, each made of letters, digits, _, - and . (not starting with a dot). A language is a directory name made of letters, digits, _ and -. Any other name, for example one containing .., \ or an absolute path, never touches the file system and is reported as a missing translation. This makes it safe to pass a language chosen by the visitor, although checking it against the languages you support is still the better choice.
Parameters
// 'greeting' => 'Hello, {name}!' echo $translator->locale('common/general', 'greeting', ['name' => 'John']); // Hello, John!
All placeholders are replaced in a single pass, so a value that contains {something} is never expanded again. A placeholder without a matching parameter is left as it is.
Values can be strings, numbers, null, bool, enums and Stringable objects. Other types (arrays, plain objects) trigger an E_USER_WARNING and render as an empty string.
Escaping
Parameter values are HTML-escaped by default, because translations usually end up in HTML and values often come from users. The translation text itself is trusted and may contain markup:
// 'welcome' => '<strong>Welcome</strong>, {name}!' echo $translator->locale('users/profile', 'welcome', ['name' => '<script>alert(1)</script>']); // <strong>Welcome</strong>, <script>alert(1)</script>!
To insert trusted markup without escaping, pick the narrowest option:
use wUFr\Localizer\Raw; // One value, from PHP code $translator->locale('legal/terms', 'accept', [ 'link' => new Raw('<a href="/terms">' . htmlspecialchars($title) . '</a>'), ]); // Named values, e.g. from a template where objects are awkward to build $translator->locale('legal/terms', 'accept', ['link' => $html, '_raw' => ['link']]); // All values of one call $translator->locale('mail/plain', 'body', ['name' => $name, '_raw' => true]); // Every call, for output that never reaches HTML (plain-text e-mails, CLI) $translator->setEscaping(false);
Never mark user input as raw.
Numbers and dates
With ext-intl installed, typed placeholders format values for the current language:
// 'total' => 'Total: {amount, number}' → cs_CZ: "Total: 1 234,5", en_US: "Total: 1,234.5" // 'price' => 'Price: {amount, number, 2}' → exactly two fraction digits // 'share' => 'Share: {ratio, number, percent}' // 'rounded' => '{amount, number, integer}' // 'created' => 'Created {when, date}' → short | medium (default) | long | full // 'at' => 'At {when, time}' → short (default) | medium | long | full // 'on' => 'On {when, datetime}'
Date values may be a DateTimeInterface, a Unix timestamp or a date string. Without ext-intl numbers are printed as plain digits and dates as Y-m-d, H:i or Y-m-d H:i.
Pluralization
Pass the number as _counter. It is also available as the {count} placeholder unless you pass count yourself.
CLDR plural categories (recommended)
Use the CLDR plural categories of the language: zero, one, two, few, many and other. The library knows the rules of 60+ languages, including the Slavic ones where 22 and 25 need different forms, and falls back to other when a category is not defined.
// locales/pl_PL/shop/cart.php return [ 'items' => [ 0 => 'Your cart is empty', // integer keys match that exact number 'one' => '{count} produkt', // 1 'few' => '{count} produkty', // 2–4, 22–24, 32–34, … 'many' => '{count} produktów', // 5–21, 25–31, … 'other' => '{count} produktu', // fractions: 1.5 ], ]; echo $translator->locale('shop/cart', 'items', ['_counter' => 22]); // 22 produkty echo $translator->locale('shop/cart', 'items', ['_counter' => 25]); // 25 produktów
Fractions follow CLDR too: 1.5 is many in Czech and other in English. Visible decimals count, so pass '1.0' as a string to get "1.0 items" in English, while the integer 1 gives "1 item".
Languages without a built-in rule use the English one (one for exactly 1). Add or replace a rule with:
$translator->getPluralRules()->define('xx', fn(int|float|string $n): string => $n == 1 ? 'one' : 'other');
Numeric thresholds
Without category keys, integer keys are thresholds: the variant with the highest key that is less than or equal to the counter wins. The order of the keys does not matter, and a counter below every key uses the lowest one.
// 'itemCount' => [ // 0 => 'You have no items', // 1 => 'You have one item', // 2 => 'You have {count} items', // ] echo $translator->locale('shop/cart', 'itemCount', ['_counter' => 12]); // You have 12 items
Thresholds are enough for languages like English or Czech with integer counts. Use categories for languages with more complex rules.
Gender
use wUFr\Gender; // 'welcome' => [ // 'male' => 'Welcome Mr. {name}', // 'female' => 'Welcome Mrs. {name}', // 'neutral' => 'Welcome {name}', // 'entity' => 'Product: {name}', // ] echo $translator->locale('users/welcome', 'welcome', ['_gender' => Gender::Female, 'name' => 'Jane']); // Welcome Mrs. Jane
_gender accepts a Gender case (or any backed enum) or a string key; the keys are not limited to the four Gender values.
Gender and plural variants can be combined. The gender is selected first:
// 'items' => [ // 'male' => ['one' => 'He has {count} item', 'other' => 'He has {count} items'], // 'female' => ['one' => 'She has {count} item', 'other' => 'She has {count} items'], // ] echo $translator->locale('users/inventory', 'items', ['_gender' => 'female', '_counter' => 3]); // She has 3 items
Fallback languages
When the current language lacks a file or a key, the fallback languages are tried in order:
$translator->setLanguage('de_AT')->setFallbackLanguages('de_DE', 'en_US');
Plural rules and number formats follow the language the text was found in.
Layered locale directories
Pass several directories, highest priority first. Each locale file is merged across every directory that has it, so the first directory can override single keys and leave the rest of the file to the others:
$translator = new Translator([ __DIR__ . '/app/locales/', // project overrides __DIR__ . '/vendor/acme/ui/locales/', // base translations ], 'en_US'); $translator->getDirectory(); // the first directory $translator->getDirectories(); // all of them
Missing translations
A missing file, key or variant never throws and never breaks the page. By default the translator returns the file.key identifier (escaped), for example common/general.welcome.
Set a handler to log, show something else or throw:
use wUFr\Localizer\MissingTranslation; // Log and return the identifier $translator->setMissingHandler(function (MissingTranslation $missing) use ($logger): string { $logger->warning($missing->message()); return htmlspecialchars($missing->identifier()); }); // Fail loudly in development and tests $translator->setMissingHandler(fn(MissingTranslation $missing) => throw new \RuntimeException($missing->message()));
MissingTranslation has the reason (a MissingReason case: FileNotFound, KeyNotFound, InvalidName, SelectorMissing, VariantNotFound, InvalidCounter or InvalidValue), file, key, language and an optional detail. The handler's return value is output as it is, so escape anything you build from these values.
Check for a key without triggering the handler:
if ($translator->has('admin/menu', 'reports_desc')) { echo $translator->locale('admin/menu', 'reports_desc'); }
API Reference
wUFr\Translator
public function __construct(string|array $dir = 'locales/', string $lang = 'en_US') public function locale(string $file, string $key, array $params = []): string public function has(string $file, string $key): bool public function setDirectory(string|array $dir): self public function setLanguage(string $lang): self public function setFallbackLanguages(string ...$languages): self public function setEscaping(bool $escape): self public function setMissingHandler(?callable $handler): self public function setPluralRules(PluralRules $pluralRules): self public function getDirectory(): string public function getDirectories(): array public function getLanguage(): string public function getFallbackLanguages(): array public function isEscaping(): bool public function getPluralRules(): PluralRules
Reserved parameters: _counter (plural number), _gender (gender key or enum) and _raw (true or a list of parameter names to insert without escaping).
Translator implements wUFr\Localizer\TranslatorInterface (locale() and has()), which is the type to depend on in your own code.
wUFr\Gender
enum Gender: string { case Male = 'male'; case Female = 'female'; case Neutral = 'neutral'; case Entity = 'entity'; public function getDescription(): string }
getPronoun(), getPossessivePronoun() and getObjectPronoun() still exist but are deprecated: they return English words only. Put pronouns into your locale files as gender variants instead.
Security
See SECURITY.md for what the library guarantees and how to report a vulnerability.
Contributing
Create a new branch from the release-candidate branch and submit a pull request. Run the checks before pushing:
composer test # PHPUnit composer analyse # PHPStan
Commit messages follow Conventional Commits, which drive the automatic releases.
License
This project is licensed under the MIT License - see the LICENSE.md file for details.