laminas/laminas-i18n
laminas-i18n provides internationalization tools for PHP applications: translations and plurals, locale-aware number, currency, date and time formatting, plus validators and view helpers for country and phone data.
Reality check first. This is not a Laravel package. It is a Laminas/Mezzio component with no service provider, no facade, and (in v3) no view helpers, filters, validators, or MVC module — all of that was extracted or deleted in 3.0. You will be wiring it by hand. Day-to-day it earns its place in a Laravel app in exactly three situations:
.po/.mo files and you want real gettext plural rules instead of hand-written | pipes.CountryCode::tryFromString()) and don't want a second big dependency.If you only need .mo loading, note that symfony/translation ships an MoFileLoader and is already deep in your dependency tree — one class beats one framework component. Be honest with yourself about which case you're in.
Minimal steps
composer require laminas/laminas-i18n
php -m | grep intl # required in practice: locale fallback + country code data
composer show laminas/laminas-i18n # v2 vs v3 changes everything
Where to look first
src/Translator/ — Translator, Loader/ (PhpArray, Gettext, Ini), Plural/src/CountryCode.php and the country-code list factorysrc/I18nDefaults.php, DefaultLocale, TextDomain (v3 additions — these are where the new defaults live)docs.laminas.dev/laminas-i18n — make sure you are reading the v3 tree; nearly every blog post you'll find is ZF2/ZF3-era and describes config keys that no longer exist.First use case: gettext files behind a service provider
// app/Providers/I18nServiceProvider.php
use Laminas\I18n\Translator\Translator;
public function register(): void
{
$this->app->singleton(Translator::class, function () {
$translator = new Translator();
// v2 API; v3 feeds the same Gettext loader through a message collector
$translator->addTranslationFile(
'gettext',
lang_path('i18n/de_DE/LC_MESSAGES/messages.mo'),
'messages',
'de_DE',
);
return $translator;
});
}
Wrap it immediately — never let Laminas\I18n\Translator\Translator leak into your controllers:
final class GettextTranslator
{
public function __construct(private readonly Translator $translator) {}
public function get(string $id, string $domain = 'messages', ?string $locale = null): string
{
return $this->translator->translate($id, $domain, $locale);
}
}
That wrapper is your seam for tests, for swapping in __(), and for surviving a major-version bump.
Pick one source of truth for locale, then sync it. This is the single biggest integration decision. Laravel's app()->getLocale() drives __(), Carbon, and validation messages; the Laminas translator has its own setLocale(). Sync in middleware or on the framework event:
final class SyncTranslatorLocale
{
public function __construct(private Translator $translator) {}
public function handle(Request $request, Closure $next)
{
$this->translator->setLocale(app()->getLocale());
return $next($request);
}
}
Prefer an event listener on Illuminate\Foundation\Events\LocaleUpdated if locale is switched mid-request (e.g. by an admin impersonation feature) — middleware alone will miss it.
Domain-per-feature, not one giant file. Text domains are the intended unit of granularity and they map cleanly onto your app's bounded contexts:
lang/i18n/de_DE/LC_MESSAGES/messages.mo
lang/i18n/de_DE/LC_MESSAGES/validation.mo
lang/i18n/de_DE/LC_MESSAGES/emails.mo
Domains also let you cache, invalidate, and lazy-load independently. Register only the domain a request actually needs, which matters if your emails domain is 4,000 strings.
PhpArray for anything you generate. If a translation-management tool or an admin UI produces PHP arrays rather than .po, the PhpArray loader is your bridge and it's faster than gettext parsing:
// lang/i18n/de_DE/messages.php
return [
'Welcome back' => 'Willkommen zurück',
'You have :count new' => [':count neue Nachricht', ':count neue Nachrichten'],
];
The loader maps message IDs to a string or an array of plural forms. This is the pattern that lets you keep Laravel's existing lang/en/*.php files as the canonical source and generate per-locale arrays from them in a build step — no translator retraining.
Pluralisation is where this package actually pays for itself. trans_choice requires you to hand-write the rule per string ({0} none|{1} one|[2,*] many) and get Polish, Russian, and Arabic right every single time. Gettext plural formulas live in the .mo header and are applied automatically:
// Same call site for every locale; the formula comes from the file
$translator->translatePlural('%d apple', '%d apples', $count, 'messages', 'pl_PL');
Test with 0, 1, 2, 5, 22 — those five values expose almost every broken plural rule.
Caching: use PSR-16, and use Laravel's store directly. Illuminate\Contracts\Cache\Repository extends Psr\SimpleCache\CacheInterface, so it drops straight into the PSR-16 collector (PR #198). Wrap each domain+locale in its own key:
$this->app->singleton(Translator::class, fn () => new Translator(
new CachingCollector($inner, cache()->store('file'), 'i18n')
));
If the collector you need is PSR-6 only (PR #195), bridge it with symfony/cache rather than pulling in laminas/laminas-cache — that dependency was deliberately removed in v3 and re-adding it defeats the upgrade.
Country codes as a validation rule. CountryCode is a backed enum, and 2.33 reworked tryFromString() to avoid the try/catch-per-case loop, so it's cheap enough for hot paths:
final class CountryCodeRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || CountryCode::tryFromString($value) === null) {
$fail('validation.country_code')->translate();
}
}
}
Prefer this over in: rules built from a hardcoded array — the list stays current with the package, and you get the enum back for ->value/->name downstream.
Don't try to bind it to Laravel's Translator contract. Illuminate\Contracts\Translation\Translator is a wide interface (get, choice, getLocale, setLocale, getLoader, addNamespace...) and implementing it as a proxy around a Laminas translator produces a Frankenstein that surprises everyone. Keep two translators, two clearly-named wrappers, and document which one owns which strings.
v3 deleted the parts every tutorial mentions. Filters, validators, view helpers, the MVC Module class, TranslatorInterface, TranslatorAwareInterface, and Translator::factory() are all gone. If an upgrade breaks your build, it's almost certainly one of these — and the fix is a separate extracted package, not a version pin.
Old config arrays are dead. The translator => ['locale' => ..., 'translation_file_patterns' => [...]] structure you'll find in most search results is MVC-module configuration. In a Laravel app it does nothing at all — no error, no translations, just silence. Wire everything explicitly.
.po files are not read. The gettext loader reads compiled .mo only. This is the #1 "my translations aren't showing" cause for developers coming from Laravel's PHP arrays. Compile in CI (msgfmt -o messages.mo messages.po) and commit the .mo, or compile on deploy.
Text domain mismatch fails silently. The gettext domain must match the .mo filename. Pass 'validation' when the file is messages.mo and you get the untranslated message ID back with no warning. A translator with no matching messages returns the input unchanged by design (per the README) — that silence is a feature until it's a debugging nightmare.
Ini plurals were broken before 3.0 (PR #212). If you're pinned to 2.x and using the Ini loader for pluralised strings, you're shipping single-form output. Another reason to prefer PhpArray or gettext.
Don't trust the factories. Every factory in this package is marked @internal. They exist for Laminas' own DI container, not as public API, and v3 changed their constructor signatures freely. Construct objects yourself in your service provider.
Clear the right cache. php artisan cache:clear only clears a collector cache backed by Laravel's store. If someone wired a file-based PSR-6 pool, stale translations survive deploys and nobody understands why. Key your cache with a hash of the .mo mtime or a build ID.
Watch the plural index, not the count. The gettext formula returns a form index, and for French n=0 maps to singular while English maps it to plural. Assert on rendered output, never on the numeric index — the index is meaningless across locales.
Debug with an isolation harness, not your app. Instantiate a bare translator in php artisan tinker, load one file, and call translate() directly. If that works but the app doesn't, the problem is locale, domain, or caching — never the loader. This cuts the debugging surface from four variables to one.
Adoption risk is real. 83 stars, zero dependents listed, and maintenance concentrated in a very small number of contributors. That's fine for a bounded use case (gettext parsing, plural formulas, country codes — all stable, well-specified problems) but a poor bet as the foundation of your entire i18n layer. Isolate it behind your own interface so replacing it stays a one-file change.
Prefer CountryCode::tryFromString() over from(). The throwing variant turns a user typing "UK" into a 500 unless you remember to catch. tryFromString() returning null is the ergonomic form and — post-2.33 — the fast one. Verify how it treats whitespace and lowercase input in a test rather than assuming.
How can I help you explore Laravel packages today?