Repository navigation
I18n Guide
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.
$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-->| 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
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::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.
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
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')).
# 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.bannerSame 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.
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).
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
HTML.PHP 5.0.0 Β· MIT Β© Mikhail Deynekin Β· Repository Β· Releases Β· Report a security issue
Wiki pages mirror docs/ β found a mistake? Edit the page or open an issue.
π Home Problem Domain Getting Started
Template Syntax Expressions & Math SSI Guide I18n Guide WordPress Integration CLI
Configuration API Cheatsheet Diagnostics & Troubleshooting
Architecture Security Model Testing & CI
πΊοΈ ROADMAP Migration 4.x β 5.0 Migration 3.x β 4.0 Contributing Repository β