laminas/laminas-translator
Laminas Translator provides a locale-aware translation layer for PHP applications, with adapters, pluralization support, and integration with Laminas components for managing translated messages and catalogs.
First, calibrate expectations: laminas/laminas-translator is an interface-only package. It contains Laminas\Translator\TranslatorInterface (two methods: translate() and translatePlural()) and, since 1.3.0, a DEFAULT_TEXT_DOMAIN constant. There is no translator implementation, no file loader, no service provider, and no Laravel bindings. Installing it adds roughly a single PHP file to vendor/.
composer require laminas/laminas-translator
Then read vendor/laminas/laminas-translator/src/TranslatorInterface.php before writing any code — the method signatures differ between the 1.x line (untyped parameters) and 2.0.0 (native parameter/return types), and this determines whether your adapter will fatal-error on the next composer update.
The one realistic day-to-day use case in a Laravel app is decoupling a shared/domain package from Illuminate\Contracts\Translation\Translator. If you maintain a library that is consumed by both Laminas/Mezzio and Laravel projects, you type-hint Laminas\Translator\TranslatorInterface inside that library, and in Laravel you register an adapter that wraps the framework's translator.
namespace App\I18n;
use Illuminate\Contracts\Translation\Translator as LaravelTranslator;
use Laminas\Translator\TranslatorInterface;
final class LaminasTranslatorAdapter implements TranslatorInterface
{
public function __construct(private LaravelTranslator $translator) {}
public function translate(string $message, ?string $textDomain = 'default', ?string $locale = null): string
{
return (string) $this->translator->get($message, [], $locale);
}
public function translatePlural(
string $singular,
string $plural,
int $number,
?string $textDomain = 'default',
?string $locale = null
): string {
return (string) $this->translator->choice("{$singular}|{$plural}", $number, [], $locale);
}
}
Bind it once in a service provider and the rest of your app (and any shared package) can resolve the interface by type:
// AppServiceProvider::register()
$this->app->bind(\Laminas\Translator\TranslatorInterface::class, LaminasTranslatorAdapter::class);
Adapter wrapping Illuminate\Contracts\Translation\Translator. This is the canonical pattern — shown above. It keeps your Laravel code idiomatically Laravel (you keep lang/, @lang, validation messages, trans_choice, php artisan lang:publish) while exposing the Laminas contract to packages that expect it.
Publishing a framework-agnostic package. The real value of the package is that a composer library author can depend on a translator contract without pulling in laminas-i18n or Laravel. Your library's constructor takes Laminas\Translator\TranslatorInterface; host applications provide the implementation. In Laravel, that implementation is the adapter above.
Text-domain mapping. Laminas passes a $textDomain argument per call; Laravel keys translations by file group (lang/en/messages.php → __('messages.welcome')). The two concepts are close but not identical. The pragmatic mapping is to treat $textDomain as the Laravel file group and concatenate: $this->translator->get("{$textDomain}.{$message}", [], $locale). If you never use multiple domains, ignore the parameter entirely rather than half-implementing it — inconsistent mapping is worse than no mapping.
Locale handling. Laminas passes a locale per call; Laravel has a global app locale but its get()/choice() methods accept a $locale argument directly. Always forward $locale into that third/fourth parameter rather than calling App::setLocale() — the latter mutates global state and is unsafe in queued jobs, Octane workers, and concurrent requests.
Testing. Because it's a two-method interface, write a trivial in-memory fake for unit tests instead of mocking:
final class ArrayTranslator implements TranslatorInterface
{
public function __construct(private array $catalog) {}
public function translate(string $message, ?string $textDomain = 'default', ?string $locale = null): string
{
return $this->catalog[$textDomain][$message] ?? $message;
}
public function translatePlural(string $s, string $p, int $n, ?string $td = 'default', ?string $l = null): string
{
return $n === 1 ? $s : $p;
}
}
Keeping translation out of Blade. Do not try to reach for this interface inside Blade templates — use __() / @lang there. Only type-hint the interface in services, actions, jobs, and mailables, so you retain the option of swapping implementations.
laminas/laminas-i18n (Laminas\I18n\Translator\Translator), which implements this interface. laminas-translator is only the contract.public function translate($message, $textDomain = 'default', $locale = null)), upgrading to 2.0.0 produces a PHP fatal error about incompatible signatures. Pin the major version in composer.json and treat any bump as a migration.DEFAULT_TEXT_DOMAIN. Introduced in 1.3.0. Use the constant (rather than the string 'default') in default parameter values so your adapter and any Laminas-side code stay in sync.Illuminate\Contracts\Translation\Translator::get() returns array|string; the Laminas interface returns string. Cast with (string) in the adapter — otherwise a caller passing an array key gets an array back and PHP 8 strict types blow up downstream.MessageSelector uses interval syntax ({1} one|[2,*] many); Laminas provides translatePlural($singular, $plural, $number) with its own rule set. Concatenating to "{$singular}|{$plural}" works for two-form languages (English, German) but is wrong for Russian, Polish, Arabic, and other multi-form locales. If you need those, build the interval string explicitly from the locale, or accept that this adapter is English-only and document it.config/laminas-translator.php. There is nothing to publish. The only setup is your own container binding.translate() echoes your string back, the bug is in your catalog or group-name mapping, not in this package.How can I help you explore Laravel packages today?