Skip to content

I18n Guide

Mikhail Deynekin edited this page Oct 11, 2026 · 1 revision

I18n Guide β€” translations with actual tooling

Gettext-compatible i18n without ext-gettext, without msgfmt on the deploy path, and β€” the part everyone skips β€” with extraction and audit commands so "which strings are still English on the German site?" is a CI answer, not a support ticket.

Note

Canonical spec: docs/I18N.md.

60-second setup

$engine = Engine::create([
    'i18n' => [
        'locale'   => 'de_DE',
        'fallback' => ['de', 'en'],
        'domain'   => 'messages',
        'paths'    => [__DIR__ . '/languages'],   // lazy-loaded on first use
    ],
]);

$engine->t('Hello {name}', ['name' => 'Welt']);        // named interpolation
$engine->n('{0} day', '{0} days', $n, [$n]);           // sprintf-style + plurals
$engine->x('File', 'menu');                            // msgctxt
$engine->renderString('@{page.title}');                // in templates
<!-- in templates -->
<h1>@{cart.title}</h1>
<!--@Trans count="%{n}"-->%{n} item<!--@Plural-->%{n} items<!--@EndTrans-->

Catalogue formats

Format Loader Notes
.po Catalog::fromPo msgctxt, plurals, fuzzy (ignored), multiline, translator comments, charset conversion (iconv/mbstring)
.mo Catalog::fromMo binary, both endians, hand-built in tests
.json Catalog::fromArray {"headers": {…}, "msgid": "str", "plural id": ["one","many"], "ctx\u0004id": "…"}
.php Catalog::fromArray same array shape, returned β€” opcache-friendly
in-memory Translator::register() databases, APIs, tests

Discovery conventions per configured path (first match per path wins; later paths merge, earlier entries take precedence β€” theme-over-plugin):

{path}/{domain}-{locale}.mo|.po|.json|.php
{path}/{locale}.mo|.po|.json|.php
{path}/{locale}/LC_MESSAGES/{domain}.mo|.po

Plural rules

The catalogue's Plural-Forms header is evaluated by a purpose-built safe evaluator (C-subset: ternary, ||, &&, comparisons, %, arithmetic on n) β€” never eval(); malformed/over-long expressions fail safe to form 0. Header missing? Built-in rules cover 60+ languages:

Family Examples Rule shape
Germanic en, de, nl, sv, da… n != 1 (2 forms)
French (fixed in v4 β€” v3 lacked it) fr, pt, kab n > 1 β€” 0 and 1 are singular
Slavic ru, uk, be, bs, hr, sr 3 forms (%10/%100 cascade)
Polish / Czech / Slovak pl, cs, sk 3 forms (own cascades)
Baltic lt, lv 3 forms
Celtic ga (5), cy/gd (4) special cascades
Arabic ar 6 forms (0, 1, 2, 3–10, 11–99, 100+)
Single-form ja, zh, ko, th, vi, id, ms, fa… always 0

Locale handling

  • Locale::normalize(): de-de β†’ de_DE; strips codesets/modifiers (ru_RU.UTF-8@euro β†’ ru_RU); junk β†’ en.
  • Negotiation: Locale::negotiate($available, $acceptLanguage, $default) β€” q-value aware, exact β†’ language β†’ default. You pass the header (RequestContext::$acceptLanguage); the engine never sniffs.
  • Per-render switch without global effect: new RenderOptions(locale: 'fr') (restored afterwards β€” safe in workers and multi-tenant requests).
  • WordPress: delegation to determine_locale() is automatic.

Resolution order (every lookup)

flowchart LR
    A[lookup] --> B{WP delegation\nenabled?}
    B -- hit --> Z[interpolate β†’ filter β†’ out]
    B -- miss/off --> C{own catalogues\nlocale β†’ fallback chain}
    C -- hit --> Z
    C -- miss --> D["source fallback\ncount==1 ? singular : plural\n+ htmlphp.translate.miss event"] --> Z
Loading

Interpolation supports named {key} and sprintf %d/%1$s (list args only; a format error returns the untranslated text instead of crashing). Results pass the htmlphp.translate filter (WP: apply_filters('htmlphp_translate')).

The tooling loop (the part that changes your life)

# 1. Extract every @{key} and @Trans block into a POT skeleton (file:line refs)
bin/htmlphp i18n:extract templates/ --out=languages/messages.pot

# 2. Translate with any gettext editor (Poedit, Loco, Crowdin, your CMS…)

# 3. Audit completeness β€” exit 1 when anything is missing (CI gate!)
bin/htmlphp i18n:audit templates/ --locale=de
# Locale de: 41/52 messages translated (79%), 3 unused.
#   missing: checkout.total
#   unused:  legacy.banner

Same commands under WordPress: wp htmlphp i18n extract|audit + an admin screen tab. Dynamic %{var|translate} usages are reported as dynamic hits (static extraction can't see runtime keys) β€” prefer @{static.keys}.

Tip

Put i18n:audit --locale=<every shipped locale> into the release job. A half-translated deploy is a decision, not an accident.

Locale-aware formatting

Behind the number, date, time, datetime, ago filters:

$engine->renderString('%{total|number:2}', ['total' => 1234.5]);
// de β†’ "1.234,50" Β· en β†’ "1,234.50" Β· fr β†’ "1 234,50" (narrow nbsp) Β· ru β†’ "1 234,50"

Separator tables for ~40 languages ship built in; unknown locales use i18n.decimal_point/thousands_separator. Dates accept timestamps, numeric strings, parseable strings and DateTimeInterface; ago uses the injected clock (frozen in tests).

WordPress delegation

Inside WP, __()/\_n()/\_x()/\_nx() are consulted first with your text domain (auto-detected; force with i18n.use_wordpress: true|false). A gettext miss (source string returned) falls through to engine catalogues β€” so core/plugin language packs from translate.wordpress.org just work, and your own JSON catalogues layer on top. The bridge also exposes htmlphp_t() for themes.

➑️ Related: Template Syntax β†’ Translation blocks Β· CLI β†’ i18n commands Β· ROADMAP β†’ ICU subset, XLIFF, in-context mode

Clone this wiki locally