Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Motranslator Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The README's Object API usage block — that is the whole happy path in 8 lines.
  2. Loader::setlocale() / textdomain() / bindtextdomain() — the directory convention it expects is {boundPath}/{locale}/LC_MESSAGES/{domain}.mo.
  3. The API docs at develdocs.phpmyadmin.net/motranslator/ for Translator, MoParser, CacheInterface.
  4. The 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.

Implementation Patterns

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.

Gotchas and Tips

  • __() 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.
  • GPL-2.0 is copyleft. Distributing a proprietary Laravel app that links this library is a licensing question, not a technical one. Escalate early; the "just one package" framing hides it.
  • No locale fallback chain. Laravel's 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.
  • Missing translations fail silently. There is no "untranslated" event, no debug mode. Wrap the translator in a decorator that logs misses, or you'll discover gaps from users instead of tests.
  • APCu is typically disabled in CLI. 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.
  • Long-running workers (Octane, queue) keep stale translations. The translator is a long-lived object holding parsed strings. Restart workers on deploy or use parseIntoCache(); otherwise translated content silently lags releases.
  • Plural equations run through 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.
  • Encoding must match end to end. The MO header charset and your source strings must agree; UTF-8 everywhere or you get mojibake that looks like a translation bug. Also note 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.
  • Debugging aids: 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/....
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log