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

Gettext Bundle Laravel Package

mablae/gettext-bundle

mablae/gettext-bundle integrates GNU gettext with Symfony's translation system, letting PHP applications use .po and .mo catalogs and native gettext-style lookups alongside existing translator workflows. It helps projects reuse established gettext tooling and translation files within a Symfony bundl...

View on GitHub
Deep Wiki
Context7

Getting Started

Set expectations first. This is a Symfony bundle, not a Laravel package. If you're on Laravel, you will not use it as a dependency — you'll read it as a reference implementation for wiring GNU gettext into a PHP translation pipeline, then either vendor the relevant bits or replicate the pattern with symfony/translation's loader contract. That framing matters for every step below.

Read in this order (30–45 minutes):

  1. composer.json — check symfony/* and PHP version constraints. If it pins Symfony 3.4/4.x-era APIs you'll need to fork before you can use it at all.
  2. The Bundle subclass and its DependencyInjection/*Extension — this is where the Translator service wiring lives. Understand it before anything else.
  3. The translator/loader class that wraps PHP's gettext/ngettext/textdomain calls.
  4. The console command(s) — typically a catalogue compiler that shells out to msgfmt or walks .po files into .mo.

That's the whole surface area. If it takes you more than an hour to read the source, the package is not the constraint — the maintenance risk is going to be.

Prerequisites on the box (this is where most first attempts die):

  • ext-gettext enabled (php -m | grep gettext).
  • msgfmt available in the build image (sudo apt install gettext / apk add gettext).
  • Target locales generated on the host — locale -a must list them, or the bundle's setlocale() call silently no-ops and you get untranslated output.

First use case — the minimum viable path on Symfony:

composer require mablae/gettext-bundle

Then enable the bundle in config/bundles.php (or app/AppKernel.php on 3.4), point framework.translator at the gettext loader, place a .po at translations/<domain>.<locale>.po, compile it to .mo, and call trans('Hello', [], 'messages', 'de'). Assert the output is the German string, not the English fallback. That single assertion is your smoke test.

If you're on Laravel: skip to the loader class and read it as a pattern. Your integration target is a custom Illuminate\Translation\FileLoader subclass (or a small service in front of a .mo reader), not this bundle.


Implementation Patterns

Keep .po as the canonical artifact, always. The one pattern that survives every other decision is: .po files live in the repo, a build step compiles them to .mo, the runtime reads .mo. This is what makes the format decision reversible — you can swap the loader later and translator workflow (Poedit, Crowdin, Weblate) is unaffected.

Pattern 1 — Build-time compilation in CI. Add a step that walks lang/**/*.po and runs msgfmt --check --statistics -o <domain>.mo <po>. Fail the build on msgfmt errors or empty catalogues. Ship the .mo files as deployable artifacts, not the .po files. This is the single highest-leverage thing you can add and it takes fifteen minutes.

Pattern 2 — Parallel run behind a config toggle. Wire the gettext translator next to your existing one. For one low-risk locale (say de), route through gettext; leave everything else on the incumbent. Compare output for one release cycle. This is the migration pattern that lets you back out in a commit rather than a rollback.

Pattern 3 — Locale-by-locale rollout. Flip one locale per release. Keep a documented rollback path (the config toggle). Plural-heavy locales (pl, ru, cs, ar, cy) are the ones worth doing first — they're where gettext's ngettext plural-rule handling genuinely beats Symfony's default formats, and where you'll learn whether the format decision was worth it.

Pattern 4 — Laravel-side adapter (do not add this bundle as a dependency). Write a small GettextLoader that:

  • reads .mo at boot (or lazily, per domain),
  • caches the parsed catalogue in memory,
  • implements Laravel's load() contract so __('key') and trans_choice() keep working.

You can lift the parsing and plural logic straight from this bundle's translator class. That's ~150–200 LOC of your own code with a real owner. This is the correct Laravel answer.

Pattern 5 — TMS in front of .po. Crowdin/Lokalise/Phrase all export .po natively. Point the exchange format at .po, keep the compiled .mo as a build artifact, and your translator workflow never touches PHP code. This is the pattern that most justifies the gettext format decision economically.

Integration tips:

  • Pin the version explicitly ("x.y.*") if you vendor it. Use a commit hash if upstream is dormant.
  • Add a repo README note explaining why this is vendored and what would make you remove it. Future you will thank present you, and a reviewer will ask.
  • Add a Dependabot/Renovate watch on the fork with a named owner. Zero dependents means nobody else notices a security advisory.
  • If you're doing a msgctxt disambiguation pattern, standardize on a convention in the .po header comments — future translators need to know what msgctxt values mean.

Gotchas and Tips

PHP gettext is process-global, and that is the biggest footgun. setlocale(), putenv(), and textdomain() mutate global state. Under PHP-FPM (one request per process) this is fine. Under Octane, Swoole, or RoadRunner it will bleed locale across requests and you will chase this for a day. If your roadmap includes any long-running worker, this is a blocker, not a caveat. Workaround if you must: reset locale explicitly at request/worker start, but that's papering over a design mismatch.

Silent degradation on missing .mo. If the catalogue isn't compiled or the file isn't where the loader expects, you get the msgid back — the English string — not an error. This is by design in gettext and it's a quality trap. Mitigate with a CI assertion that every domain/locale pair has a non-empty .mo and a startup health check that logs when a requested domain resolves to zero entries.

locale -a is the classic "works locally, English in prod." Your local machine has de_DE.UTF-8 installed; your slim container image does not. setlocale(LC_ALL, 'de_DE.UTF-8') returns false and the translation silently falls through. Install locales in the Dockerfile (locale-gen) or use the xx_XX.utf8 variant — the exact string is host-dependent and worth a runbook entry.

Charset must be UTF-8 in the .po header. <charset> in the header line: get it wrong and msgfmt may warn or you may get mojibake at runtime. Also check the Plural-Forms: header per locale — a wrong plural-forms line gives subtly wrong trans_choice output for locale-specific rules (Polish has four forms, Arabic six). This is the class of bug translators can't catch and only native speakers notice.

.mo files are deployment artifacts, not source. Don't forget to include them in your release tarball/build image. If you're on Docker with .dockerignore, make sure you're not excluding compiled catalogues.

The msgctxt pattern is worth adopting deliberately. In gettext, msgctxt disambiguates same-string-different-meaning cases (msgctxt "button" / msgid "Open"). It's a real improvement over Symfony keys for UI strings. Pick a convention early (e.g., button., error., email.) and document it — retrofitting it across an existing catalogue is painful.

Extension points are thin, and that's fine. There's no plugin system, no admin UI, no extraction command for Twig/PHP strings. Extraction is delegated to xgettext, Poedit, or your TMS. If you assumed this handled extraction from source, adjust — that's a separate toolchain. If you need extraction, run xgettext in CI over resources/views and app/ and generate a .pot template.

Container/autowiring surprises on Symfony 4.x+. Services declared in the bundle's DI extension may be private by default under newer Symfony versions. If injection fails, check whether the extension marks the loader public or whether you need to alias it in services.yaml. This surfaces at container compile time, which is at least a loud failure.

Do not adopt for runtime-editable translations. gettext is compile-and-deploy. If product needs an admin UI to edit translations, per-tenant overrides, or hot-reload, gettext is the wrong format end to end and this bundle does nothing to fix that.

License obligation: MIT — keep the copyright notice if you vendor. It's a one-line LICENSE file concern and it's the only compliance item, but get it right.

Debugging checklist when a string isn't translated — in order:

  1. php -m | grep gettext — extension enabled?
  2. locale -a | grep <locale> — locale installed?
  3. ls -la <path>/<domain>.mo — compiled, non-zero size, correct mtime?
  4. msgfmt --check <file>.po — header and plural-forms valid?
  5. Does the msgid in the .po exactly match the source string (whitespace, typographic quotes, trailing punctuation)?
  6. Is the domain passed to trans() the same as the .mo filename stem?

That order catches ~90% of real-world "translations aren't showing up" tickets, and it's worth writing into your runbook before you onboard anyone else.

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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings