leaseweb/gettext-translation-bundle
A Symfony bundle that brings GNU gettext translation support to Symfony’s translator, enabling .po/.mo catalogs, translation domains, locale handling, and automatic catalog compilation and caching. Use familiar gettext workflows while integrating with Symfony services and configuration.
1. Confirm the constraint stack before anything else. The last release is 2018-02-12, which puts it in the Symfony 2.8–3.x era. Check composer show leaseweb/gettext-translation-bundle --all and the require block first — on Symfony 4.2+ transChoice() is deprecated and it is removed in 5.0, so the bundle's core method (ngettext plumbing lives there) will not survive a modern Symfony. If you are on Symfony 5/6/7, budget for adopting the fork yourself: the surface area is small (a translator subclass plus a compiler pass), which is exactly why it is worth evaluating at all.
2. Install it and its real dependency.
composer require leaseweb/gettext-translation-bundle
php -m | grep gettext # this must print "gettext"
The PHP ext-gettext extension is the whole point of the package — it is a hard requirement, not an optimization. If you cannot install ext-gettext in every environment (including CI and the prod image), stop here; the bundle has nothing to offer.
3. Register the bundle in app/AppKernel.php (or config/bundles.php on newer skeletons) and lay translations out exactly the way GNU gettext expects:
translations/gettext/
nl_NL/LC_MESSAGES/messages.mo
nl_NL/LC_MESSAGES/validators.mo
en_GB/LC_MESSAGES/messages.mo
The <path>/<locale>/LC_MESSAGES/<domain>.mo layout is not configurable — it is the libc convention.
4. Configure the domains. The shape is a default_domain plus a map of domain → path (verify exact key names against DependencyInjection/Configuration.php, since the README is the only spec and it is terse):
lsw_gettext_translation:
default_domain: messages
domains:
messages:
path: "%kernel.project_dir%/translations/gettext"
validators:
path: "%kernel.project_dir%/translations/gettext"
The bundle's compiler pass swaps the translator service for Lsw\GettextTranslationBundle\Translation\GettextTranslator, which calls bindtextdomain() / textdomain() / setlocale() and then routes trans() through gettext()/dgettext() and transChoice() through ngettext().
5. Verify the swap, then do the first real translation. Run bin/console debug:container translator --show-private and confirm the class is the bundle's, not Symfony\Bundle\FrameworkBundle\Translation\Translator. Then add Hello as a msgid in your .po, compile with msgfmt, request the page with the locale set, and confirm {{ 'Hello'|trans }} renders translated. If it renders the source string, jump straight to the locale-vs-path debugging recipe in the last section — that is the failure mode 90% of the time.
Where to read first: Translation/GettextTranslator.php (the domain→dgettext mapping and the setLocale implementation) and the Tests/ directory, which is the most reliable documentation of the expected config array.
Treat msgids as your English copy. gettext() returns the msgid verbatim when no translation is found, so the untranslated state degrades to readable English rather than messages.welcome.title. Write 'Welcome back, %name%' as the key, not 'welcome.title'. This is the single biggest design shift versus Symfony's default translator and it makes the fallback behaviour a feature instead of an embarrassment.
Set the locale once per request, in a listener. Because the translator mutates process-global state, do it explicitly and early rather than relying on framework defaults:
class LocaleListener
{
public function onKernelRequest(RequestEvent $event): void
{
$locale = $this->detect($event->getRequest()); // Accept-Language, user pref, route, ...
$this->translator->setLocale($locale); // triggers setlocale() + putenv() + textdomain()
}
}
Anything that runs before this listener (security error pages, early kernel exceptions) will render in whatever locale the worker last used.
Keep Symfony domain names and gettext domain names identical. {{ 'Too short'|trans({}, 'validators') }} should resolve to dgettext('validators', 'Too short') against validators.mo. Every domain you reference must be declared in lsw_gettext_translation.domains and must have a matching .mo basename; a mismatch fails silently to the msgid.
Pluralization is the reason to care. {{ count|transchoice(...) }} / transChoice() go to ngettext(), and the plural rules are read from the Plural-Forms header inside the .mo — not from Symfony. That means Russian, Polish and Arabic get correct 3/4/6-form behaviour for free, which Symfony's own catalogue logic historically made you configure per-locale. It also means a bad header silently breaks singular/plural selection. Run msgfmt --check in CI.
Compile .po → .mo in the pipeline, never at runtime. Keep .po under version control, add a build step:
find translations -name '*.po' -exec sh -c 'msgfmt -o "${1%.po}.mo" "$1"' _ {} \;
Commit the .mo only if you have no build step; otherwise generate them during the deploy and restart PHP-FPM afterwards (see Gotchas).
Convert Symfony's own XLIFF translations if you need the validators domain. Symfony ships its bundled translations as XLIFF, not .mo. If you want translated form/validation messages, you must convert them (xlf → po → mo) once and vendor the result, or you end up with an app whose own strings are translated but whose Too short validation messages are not.
Use placeholders normally, but keep them literal in the msgid. The bundle returns the raw gettext string and leaves %name% substitution to Symfony's parameter replacement, so 'Hello %name%'|trans({'%name%': user.name}) works. Translators must keep the placeholder spelling intact — add a msgfmt/msgcat check or a lint rule, because gettext has no compile-time protection against a translator renaming %name%.
For tests, the gettext extension and the compiled .mo files must both be present, and the locale must exist on the test machine. Save and restore the previous locale in tearDown() — setlocale() is process-wide and will leak between test cases.
The OS must actually have the locale. setlocale(LC_ALL, 'nl_NL.UTF-8') returns false and translations silently disappear if the locale is not generated on the host. Check locale -a, and in Docker add RUN locale-gen nl_NL.UTF-8 (or the locales package plus localedef). Prefer xx_XX.UTF-8 over bare xx_XX; mixing them across dev/prod is a classic "works on my machine".
Deploying new .mo files requires a PHP-FPM restart. libc/gettext caches catalogues per process and there is no supported runtime invalidation. cache:clear does nothing here, and opcache is irrelevant. If your deploy does not reload FPM, your translators' fixes will appear to be ignored until the next unrelated restart — a genuinely confusing afternoon.
Everything is process-global. setlocale, putenv('LANG=...') and textdomain affect the whole worker, not a request-scoped context. This is fine for one-locale-per-request FPM but breaks under multi-threaded SAPIs, in long-running workers that serve multiple locales concurrently (Swoole, RoadRunner), and in queue workers that process mixed-locale jobs — set the locale per job explicitly.
You lose Symfony's translation tooling. The native path never populates a Symfony MessageCatalogue, so debug:translation, the profiler's translation panel, and anything that introspects catalogue resources will show nothing useful. Your .po files and msgfmt --statistics become the source of truth for coverage; consider msgfmt --check --statistics in CI as the replacement for the profiler.
Debug outside Symfony first. When a string will not translate, reproduce it in one line to separate locale problems from path problems:
php -r 'putenv("LANG=nl_NL.UTF-8"); setlocale(LC_ALL,"nl_NL.UTF-8");
bindtextdomain("messages","translations/gettext"); textdomain("messages");
var_dump(setlocale(LC_ALL, 0)); echo gettext("Hello"), PHP_EOL;'
setlocale(LC_ALL, 0) returning C means the locale never applied (OS problem); returning nl_NL.UTF-8 while still printing Hello means the path/domain is wrong (layout problem). That single distinction saves the most time of anything in this package.
Encoding: the translator binds the codeset to UTF-8. Make sure your .po is UTF-8 and that the Content-Type header in the .po says charset=UTF-8; a Latin-1 .po will produce mojibake that looks like a Twig escaping bug.
Extension point: the translator is a plain service, so subclass GettextTranslator and override trans()/transChoice() (for logging missing translations, for instance) and re-point the service, or decorate it. Avoid touching the compiler pass — it is the piece most likely to break on a Symfony upgrade, and every line you add there is a line you re-apply on the next fork rebase.
Honest cost/benefit: the win is real for large catalogues — native gettext() avoids Symfony's .mo parser and catalogue merge on every cold cache — but for an app with a few hundred strings the performance delta is noise. You are trading XLIFF-in-a-PHP-array simplicity for OS locales, a compile step, deploy restarts, and lost debug tooling. Take the trade only if catalogue size or correct multi-form pluralization is actually hurting you, and go in expecting to own the fork: 24 stars and zero dependents means no upstream safety net.
How can I help you explore Laravel packages today?