glueful / i18n
Localization primitives for Glueful apps and extensions.
Requires
- php: ^8.3
Requires (Dev)
- glueful/framework: ^1.57.0
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.6
Suggests
- ext-intl: Enables ICU MessageFormat pluralization with full CLDR plural rules.
README
Platform localization for Glueful apps and extensions: a locale registry with single-parent fallback chains, persisted translation catalogs that overlay file catalogs, parameterized message formatting with pluralization, optional missing-key tracking, catalog import/export, an HTTP management API, and CLI tooling.
The headline integration: this extension binds the translation.manager
service id that the framework's ServiceProvider::loadMessageCatalogs() hook
looks for -- installing it is what makes every extension's messages.{locale}.php
catalogs actually load.
Install
composer require glueful/i18n php glueful extensions:enable i18n php glueful migrate:run
Requires glueful/framework >= 1.55.0. ext-intl is optional: with it,
pluralization goes through ICU MessageFormat; without it, a simple built-in
plural parser is used (see "Pluralization").
The translation.manager seam
Framework core ships ServiceProvider::loadMessageCatalogs($dir, $domain) on
every extension service provider, but it is a silent no-op unless something
binds the translation.manager container id. This extension binds its
TranslationManager to that id (and to its typed
Glueful\Extensions\I18n\Contracts\TranslatorInterface), satisfying the
expected contract:
$manager = app($context, 'translation.manager'); $manager->addMessages('en', 'messages', ['hello' => 'Hello {name}']);
With glueful/i18n installed, any extension can ship translations using the
framework's messages.{locale}.php convention -- no dependency on this
package needed:
// In your extension's ServiceProvider: public function boot(ApplicationContext $context): void { // Loads resources/lang/messages.en.php, messages.fr.php, ... into the // "my-extension" domain via translation.manager (no-op when i18n is absent). $this->loadMessageCatalogs(__DIR__ . '/../resources/lang', 'my-extension'); }
// resources/lang/messages.en.php return [ 'welcome.title' => 'Welcome, {name}', 'inbox.count' => '{count, plural, one {# message} other {# messages}}', ];
Translating
Resolve the typed contract (or translation.manager) and call trans():
use Glueful\Extensions\I18n\Contracts\TranslatorInterface; $translator = app($context, TranslatorInterface::class); $translator->trans('welcome.title', ['name' => 'Ada'], 'fr'); $translator->trans('inbox.count', ['count' => 3], 'en', 'my-extension');
{param}placeholders are replaced from the parameters array.- The lookup walks the locale's fallback chain (see below); the first bundle containing the key wins.
- A complete miss returns the key itself (and optionally records it, see "Missing-key tracking").
- When no locale is passed,
trans()uses the application default locale -- it does not inspect the current request by itself. To translate per-request, resolve the locale first and pass it explicitly:
use Glueful\Extensions\I18n\Contracts\LocaleResolverInterface; $locale = app($context, LocaleResolverInterface::class)->resolveLocale($request); $translator->trans('welcome.title', ['name' => 'Ada'], $locale);
Pluralization
There is no hard ext-intl dependency; both paths understand the same
{count, plural, one {...} other {...}} message shape:
- With
ext-intl: messages using ICU argument syntax for aplural,select, orselectordinalblock ({name, plural, ...},{name, select, ...},{name, selectordinal, ...}) are formatted by ICUMessageFormatterwith full CLDR rules for the target locale. Plain{param}messages -- including ones that merely contain the word "plural" outside such a block -- keep the cheap substitution path. - Without
ext-intl: a built-in fallback handles only the two-branchone/otherplural form in place (#is replaced with the count), then simple{param}substitution runs.select/selectordinalblocks and locale-specific plural categories beyondone/otherrequireext-intl.
trans() returns raw translated text. It does not HTML-escape parameters or
formatted output; callers must escape for the target context (HTML, attributes,
JavaScript, Markdown, CLI, etc.).
Locale resolution
LocaleResolver::resolveLocale($context) accepts a locale string, a Symfony
Request, or a Glueful\Extensions\I18n\Support\LocaleContext. Candidates are
tried in order; the first enabled locale wins:
- Explicit locale (string argument or
LocaleContext::$explicitLocale). - Request override:
?locale=query parameter orX-Localeheader (only wheni18n.request_overrideis true). - The authenticated identity's
preferred_localeclaim (auth.userrequest attribute orLocaleContext::$claims). - Tenant locale (
tenant.localerequest attribute orLocaleContext::$tenantLocale), thenLocaleContext::$appLocale. - The default locale (the stored locale flagged
is_default, falling back toi18n.default_locale).
A candidate only wins if it is enabled. When any stored locales are enabled,
the stored set is authoritative: a locale that is disabled in the database is
excluded even if it appears in i18n.enabled_locales. The config list only
applies while the i18n_locales table is empty.
Fallback chains
Each stored locale may declare a single parent via fallback_locale. For a
requested locale the chain is:
- The locale itself, then its stored parent chain (
fr-CA -> fr -> ...). - The implicit language parent derived from the code (
en-GB -> en), if not already in the chain. - The global
i18n.fallback_locale. - On a complete miss, the key itself is returned.
Cycles are rejected at write time: creating or updating a locale whose
fallback_locale would close a loop throws, so resolution never has to break
a cycle at read time.
DB translations vs file catalogs
Bundles are merged from two sources per (locale, domain):
- File catalogs registered through
translation.manager::addMessages()(typically vialoadMessageCatalogs()). - Persisted rows in
i18n_translations.
With i18n.db_overrides_catalogs set to true (the default), DB rows win per
key: ship default strings in code, override them from the database via the API
or CLI without a deploy. Set it to false to let file catalogs win instead.
Caching
Bundle caching is request-scoped memoization: merged bundles are kept in
memory keyed by locale:domain:version, and every write through the manager
bumps the version and drops the stale entry. There is no backend cache
(Redis or otherwise) in this release; each request/process rebuilds bundles
on first use.
Missing-key tracking
Off by default (i18n.missing_tracking). When enabled, a translation miss is
recorded in i18n_missing_translations (first/last seen, hit count) and
surfaced via GET /i18n/missing and i18n:missing.
Recording is rate-limited per (domain, locale, key) by
i18n.missing_rate_limit_seconds, but the limiter state lives in the recorder
instance -- in a typical PHP-FPM deployment that means per request, so the
limit mainly dedupes repeat misses within a single request (long-running
workers get the full window). Each recorded miss is 1-2 queries; leave
tracking off in production unless you are actively auditing.
Novel missing keys stop recording once i18n.missing_max_rows is reached,
while existing rows can still increment their hit count.
Configuration
config/i18n.php (merged under the i18n key):
| Key | Default | Meaning |
|---|---|---|
default_locale |
'en' |
App default; used when no stored locale is flagged is_default. |
fallback_locale |
'en' |
Global last step of every fallback chain. |
enabled_locales |
['en'] |
Allowed locales only while the i18n_locales table is empty; stored enabled locales take over once any exist. |
request_override |
true |
Honor ?locale= / X-Locale request overrides. |
missing_tracking |
false |
Record translation misses to the database. |
missing_rate_limit_seconds |
60 |
Per-key re-record window (per-request state; see above). |
missing_max_rows |
10000 |
Maximum missing-key rows; existing rows can still increment. |
db_overrides_catalogs |
true |
DB translations win over file catalogs per key. |
routes_enabled |
true |
Register the /i18n HTTP routes. |
The first stored locale is forced to enabled/default. Once a stored default exists, the repository rejects updates that would clear or disable the only default locale.
HTTP API
All routes are mounted under /i18n, require auth, and are gated by the
i18n_permission middleware (see "Permissions"). Disable the whole surface
with i18n.routes_enabled = false.
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /i18n/locales |
i18n.view |
List stored locales. |
| POST | /i18n/locales |
i18n.manage |
Create a locale (code + name required; cycle-checked fallback_locale). |
| PATCH | /i18n/locales/{code} |
i18n.manage |
Update a locale; is_default: true clears the previous default. |
| GET | /i18n/translations |
i18n.view |
List translations (?locale=, ?domain= filters). |
| POST | /i18n/translations |
i18n.manage |
Upsert a translation on (domain, locale, key). |
| PATCH | /i18n/translations/{uuid} |
i18n.manage |
Update one translation's value. |
| GET | /i18n/missing |
i18n.view |
List recorded missing keys (?locale=, ?domain= filters). |
| POST | /i18n/import |
i18n.import |
Import an inline JSON catalog from the catalog payload field. |
| GET | /i18n/export |
i18n.export |
Export translations as a JSON catalog (?locale=, ?domain= filters). |
Error envelopes: write payloads are validated by I18nPayloadValidator
(unknown fields are stripped). Invalid input -- missing or malformed fields,
duplicate locale codes, fallback cycles, empty update payloads, attempts to
change a locale code, and malformed import catalogs -- returns HTTP 422
with the standard error envelope, field-keyed messages under
error.details. An unknown locale {code} or translation {uuid} returns
HTTP 404. Input errors never surface as 500.
CLI
| Command | What it actually does |
|---|---|
i18n:locales |
Tables all stored locales (code, name, enabled, default). |
i18n:missing |
Tables all recorded missing translations with hit counts. |
i18n:sync-catalogs <directory> [domain] |
Globs messages.*.php files in the directory, derives the locale from each filename, and upserts every scalar key/value into i18n_translations under the given domain (default messages). One-way: files into DB. |
i18n:validate |
Builds the set of known (domain, key) identities from stored translations and reports, per enabled locale, every identity that locale is missing. Exits non-zero when gaps exist. DB-only: file catalogs are not inspected. |
i18n:import <file> |
Imports a JSON or PHP catalog file from a trusted operator-supplied path. |
i18n:export [locale] |
Prints the JSON catalog for all or one locale. |
HTTP catalog import accepts an inline JSON catalog object or array. CLI
catalog import accepts JSON and PHP-array files from trusted operator-supplied
paths. Both forms round-trip four fields per row: domain, locale, key,
value -- an exported catalog can be re-imported losslessly.
Translation values are capped at 65,535 bytes across HTTP, CLI import, and
direct repository writes.
Permissions
The extension registers four permissions in the framework permission catalog
and ships its own i18n_permission route middleware:
i18n.view-- read locales, translations, and missing keys.i18n.manage-- create/update locales and translations.i18n.import-- import catalogs.i18n.export-- export catalogs.
The middleware calls PermissionManager::can() directly and fails closed:
no authenticated identity, no resolvable PermissionManager, or a denied
check all return 403.
Boundaries
This extension owns platform localization: UI strings, supported locales, fallback rules, and translation catalogs for app/extension surfaces. Content platforms such as Lemma own content localization -- localized content fields, slugs, routes, publishing state, and editorial translation workflow. Lemma should consume this extension's locale registry and translator rather than re-implement them, but its content models stay on its side of the line.
Likewise, large asynchronous import/export orchestration (batched jobs,
progress tracking, validation reports) belongs to glueful/import-export;
this extension only owns catalog-shaped payloads and their synchronous
round-trips.
Schema
One migration creates three tables:
i18n_locales-- locale registry (codeunique,enabled,is_default,fallback_locale,direction,region).i18n_translations-- persisted catalog rows, unique per(domain, locale, key), withstatusandsourcecolumns.i18n_missing_translations-- recorded misses, unique per(domain, locale, key), with first/last seen and hit count.