phpmyadmin/motranslator
A PHP library for translating strings using Gettext MO files, with fast in-memory lookup, a Gettext-compatible API, and support for plural forms and contexts. No eval required.
Install and pin the right major. composer require phpmyadmin/motranslator pulls v6.x, which requires PHP 8.2+ and ships native type declarations (5.4 supported 7.2+). Check composer why-not phpmyadmin/motranslator 6.0.1 before assuming the latest works on your runtime. Note the license is GPL-2.0 — that is a real decision point for a closed-source product; flag it before it leaks into an app/ dependency graph.
Where to look first, in order:
Loader::setlocale() / textdomain() / bindtextdomain() — the directory convention it expects is {boundPath}/{locale}/LC_MESSAGES/{domain}.mo.develdocs.phpmyadmin.net/motranslator/ for Translator, MoParser, CacheInterface.tests/ directory for fixture .mo files — the fastest way to see plural headers and msgctxt handling behave.First use case (30 minutes): pick one domain, drop a compiled .mo in lang/cs/LC_MESSAGES/app.mo, and translate a single string outside Laravel's translator:
$loader = new PhpMyAdmin\MoTranslator\Loader();
$loader->setlocale('cs');
$loader->textdomain('app');
$loader->bindtextdomain('app', base_path('lang'));
$t = $loader->getTranslator();
echo $t->gettext('Type'); // "Typ"
echo $t->ngettext('%d row', '%d rows', $count); // correct Czech plural form
Do this in php artisan tinker first. If strings come back untranslated, the path convention or the locale string is wrong — not the library.
Bind a repository, not a bare Translator. Loader holds one locale and one current domain, so multi-domain apps need one Loader per domain. A tiny resolver keeps this honest and lazy:
final class MoRepository
{
/** @var array<string, Translator> */
private array $translators = [];
public function __construct(
private readonly string $locale,
private readonly array $paths, // ['app' => base_path('lang'), 'admin' => ...]
) {}
public function translator(string $domain = 'app'): Translator
{
return $this->translators[$domain] ??= $this->build($domain);
}
private function build(string $domain): Translator
{
$loader = new Loader();
$loader->setlocale($this->locale);
$loader->textdomain($domain);
$loader->bindtextdomain($domain, $this->paths[$domain]);
return $loader->getTranslator();
}
}
Register it as a scoped singleton keyed off the request locale, and expose t() / tn() helpers so Blade doesn't reach for app(MoRepository::class).
Locale switching belongs in middleware, and it must rebuild the repository. Because locale is captured at construction time, a SetMoLocale middleware (after SetLocale) should call app()->forgetInstance(MoRepository::class) and rebind — otherwise a user changing language mid-session keeps the previous .mo.
Use APCu in production, set it before getTranslator(). This is the difference between parsing .mo per request and native-gettext-class performance:
Loader::setCacheFactory(new ApcuCacheFactory(
ttl: 0, // until restart
reloadOnMiss: true,
prefix: 'mo_' . config('app.version') . '_', // bust on deploy
));
The prefix trick is the pragmatic cache-buster: bumping app.version invalidates every entry without touching APCu manually.
Compile .po → .mo in the build pipeline, never at runtime. An Artisan command shelling out to msgfmt (from gettext), run in CI/Docker build:
foreach (File::glob(lang_path('*/*.po')) as $po) {
$mo = preg_replace('/\.po$/', '.mo', $po);
exec(sprintf('msgfmt -o %s %s', escapeshellarg($mo), escapeshellarg($po)), $out, $code);
abort_if($code !== 0, 500, "msgfmt failed for {$po}");
}
Translators get .po (Poedit, Weblate), the app ships .mo. Commit one, build the other.
Hot-reload translations without restarting PHP-FPM. With APCu, MoParser::parseIntoCache() is the supported republish path after uploading new .mo files:
$parser = new MoParser($path);
$parser->parseIntoCache(new ApcuCache($parser, $locale, $domain));
Wire this to a deploy hook or an authorized admin action.
Test with new Translator(null) + setTranslations(). No fixtures, no filesystem, no locale baggage:
$t = new Translator(null);
$t->setTranslations(['Type' => 'Typ', 'Test 2' => 'Test 2 translation']);
$this->assertSame('Typ', $t->gettext('Type'));
Also useful for per-tenant string overrides layered on top of a base .mo.
Reach for the low-level API when you already have parsed data — MoParser + InMemoryCache + Translator lets you skip Loader's global-ish state entirely, which is what you want in queue workers or tests that run many locales in one process.
Only load the _gettext() compatibility layer for legacy code. Loader::loadFunctions() defines _gettext, _n, _pgettext, _dgettext, etc. globally. Keep it behind a flag and confine it to the legacy namespace you're migrating — do not let it become the house style for new code.
__() will not be yours. loadFunctions() installs __() but guards every definition with function_exists(). Laravel's __() is autoloaded first, so motranslator's version never wins. Use _gettext() / _n() if you need the compat layer, and never mix __() and motranslator in the same view — you'll get half-translated pages.fallback_locale has no equivalent here. cs_CZ missing a string returns the raw msgid, not the en translation. Implement fallback yourself (try locale, then fallback, then msgid) or accept English-as-msgid discipline from day one.apc.enable_cli=0 means Artisan commands and queue workers silently get no shared cache (or fail depending on config). Verify with php -i | grep apc.enable_cli before benchmarking; ApcuCache also needs the extension present — guard with extension_loaded('apcu').reloadOnMiss has a sharp edge. true (default) re-parses the whole .mo on every miss — a partial translation file on a busy page is a performance cliff. false means evicted entries serve untranslated text. Size apc.shm_size deliberately and monitor hit rate during rollout.Loader::setCacheFactory() is static. It leaks across tests and across requests in Octane. Reset it in tearDown, and reset it in Octane's request lifecycle if you ever switch cache strategies.parseIntoCache(); otherwise translated content silently lags releases.symfony/expression-language, not eval(). That's the security win (CVE-2016-6175) but it adds a dependency and a parse cost per locale. Corrupt or unvalidated plural headers throw — expect this on hand-edited .mo files and validate in CI.Translator does no sprintf interpolation — you pass '%d rows' and call sprintf yourself, which means placeholder mistakes are runtime-silent.msgctxt keys are invisible at the call site. pgettext('Context', 'String') is stringly-typed; a typo yields the untranslated msgid with no warning. Enforce context constants in code review.InMemoryCache loads the whole file per process. Fine for a domain or two; for a large multi-domain catalog under heavy FPM concurrency, measure RSS before shipping — this is precisely the case APCu exists for.getTranslations() dumps the full loaded map (added in 5.2.0) — useful to confirm what was parsed versus what you expected. Pair with a dd() of the MO path you actually bound; nine times out of ten the bug is lang/cs/LC_MESSAGES/app.mo versus lang/cs_CZ/....How can I help you explore Laravel packages today?