Internationalisation (i18n)¶
Last reviewed: 2026-07-30
DiΓ‘taxis: explanation. This page explains how Project NEXUS is translated and the invariants that keep it translatable. For the contributor workflow, see .github/LOCALIZATION_WORKFLOW.md.
Project NEXUS is a global platform. All user-facing text is translatable. The React and Laravel surfaces ship 11 languages: English, Irish (Gaeilge), German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese, and Arabic (with full right-to-left support). The native mobile client currently packages the supported subset English, Irish, German, French, Italian, Portuguese, and Spanish; do not claim native coverage for a locale until its complete mobile catalogue and native metadata are shipped.
Where strings live¶
| Surface | Mechanism | Source files |
|---|---|---|
| React frontend | t('key') via react-i18next |
react-frontend/public/locales/<lang>/<namespace>.json |
| Accessible (GOV.UK) frontend | Laravel translation keys | lang/en/govuk_alpha.php (+ locale variants) |
| Emails & notifications (PHP) | __('emails.section.key'), __('notifications...') |
lang/<lang>/<namespace>.json first, lang/<lang>/<namespace>.php as fallback β see "Which file actually serves a PHP namespace" below |
| Mobile (Expo) | i18next + expo-localization |
mobile/locales/<lang>/<namespace>.json |
The React app splits strings into per-module namespaces (e.g. common, public, wallet, events). The default namespace is common.
π΄ A namespace name can exist in both trees and mean different things.
react-frontend/public/locales/<lang>/admin_nav.json is a live React namespace
used by 39 components; lang/<lang>/admin_nav.php was a PHP copy of it with zero
call sites and was deleted on 2026-07-29. Same name, different tree, different
lifecycle. Always confirm which tree a namespace belongs to before deleting or
bulk-editing it.
The two hard rules¶
1. No hardcoded user-facing strings¶
Every label, subject line, button, and body paragraph in end-user output must go through a translation function β t('key') in React, __('key') in PHP. Hardcoded English is a defect: it makes the feature untranslatable. CI enforces this (scripts/check-i18n.sh and the PHP/React i18n checks).
2. Emails and notifications render in the recipient's language¶
A notification must render in the recipient's preferred_language β not the HTTP caller's locale, not the queue worker's default. Laravel's __() resolves against App::getLocale() at call time, so any service, listener, or queue job that renders a notification must wrap the render-and-send block in:
use App\I18n\LocaleContext;
LocaleContext::withLocale($recipient, function () use ($recipient, $mailer, $body) {
$subject = __('emails.report.subject'); // now renders in the recipient's language
$mailer->send($recipient->email, $subject, $body);
});
Every user SELECT feeding a notification must include preferred_language. The regression test is tests/Laravel/Feature/I18n/EmailLocaleIntegrationTest.php. See the "Email & notification locale" rule in AGENTS.md.
Quality gates¶
The i18n checks (run in CI and locally) are:
| Check | Purpose |
|---|---|
node scripts/check-i18n-drift.mjs |
Every locale file must match the English key structure (no missing/extra keys). |
node scripts/check-php-lang-parity.mjs |
Same structural check for the PHP lang/**/*.php tree. Key sets only β see the warning below. |
node scripts/check-php-lang-untranslated.mjs |
BLOCKING shrink-only ratchet. Counts PHP lang values that are byte-identical to English. Ceiling in .github/php-lang-untranslated-baseline.json. |
node scripts/check-i18n-coverage.mjs / check-php-i18n-coverage.mjs |
Translation completeness against English. |
node scripts/check-i18n-gap-regression.mjs |
Fails if the untranslated / English-fallback debt grows beyond the committed baseline. |
node scripts/check-i18n-literals.mjs, check-i18n-stubs.mjs, check-i18n-vars.mjs |
Catch hardcoded literals, stub values, and placeholder mismatches. |
npm run check:i18n:baseline / check:i18n:gaps |
Aggregate baseline + gap reports. |
Baselines live in .github/i18n-*-baseline.json and .github/php-lang-untranslated-baseline.json.
π΄ Why parity alone is not enough¶
A key-set check cannot see a wrong value. When the untranslated ratchet was first written it found that 62.3% of all non-English PHP lang values β 99,139 of 159,140 β were byte-identical English, with the parity gate green the whole time. Copying the English value across to satisfy parity is the mechanism that produced that, so it is not an acceptable shortcut. The debt is now down to 249, and the ratchet exists to keep it there.
A value that is correct because it matches English β a brand name, an SI unit,
a language endonym (endonyms are written the same in every language by
definition), or a string containing nothing but placeholders β belongs in
scripts/php-lang-invariant-allowlist.json. Global entries apply everywhere;
byLocale entries must survive the gate's own check, which refuses an entry when
that locale renders the same English value differently somewhere else in lang/
β because that proves a translator did translate it.
Which file actually serves a PHP namespace¶
__() (app/helpers.php) asks App\I18n\Translator first, which reads
lang/<locale>/<namespace>.**json**, and only falls back to Laravel's .php
loader when that returns the key unchanged. A .php namespace can therefore be
completely unreachable while the live JSON beside it is completely untranslated β
and the ratchet scans .php only, so it cannot see that. Check which of the two
files serves a namespace before translating either.
Right-to-left (Arabic)¶
Arabic (ar) renders right-to-left. The frontend flips layout direction automatically; the accessible frontend applies dir="rtl".
Irish (ga) needs the OpenAI path only on the DeepL route, which does not
support the language. Google Translate does support Irish, and the --google
paths in both translation helpers handle it like any other locale β do not skip
or defer ga when using them.
Adding or changing a string¶
- Add or change the English source key first.
- Add the key to every other locale file, then translate it. Adding it with the English value satisfies structural parity and nothing else β see the warning above. Use the translation helpers:
- React JSON β
node scripts/translate-i18n-gaps.mjs --google - PHP
lang/*.phpβnode scripts/translate-php-lang-gaps.mjs --google --namespace <file>.php(a separate script; the React one deliberately excludes PHP)
The PHP helper only fills keys that already exist in the locale file, so
step 2's insertion has to happen first. It also keeps a checkpoint at
.local-docs-archive/php-lang-translate-checkpoint.json; clear the namespace
from it before a re-run or the script reports the batch as already done.
3. Read the output before trusting it. The placeholder guard protects :name-style
tokens, but machine translation will still capitalise a literal request field
name (peer_slug β Peer_slug) or turn a technical term into an unrelated word.
4. Run the i18n checks above and confirm no baseline regresses.
5. For non-English locale changes, declare Translation Status: and Translation Reviewer: in the pull-request description (a CI gate enforces this).
See .github/LOCALIZATION_WORKFLOW.md for the full review states and the "acceptable residual English" policy for admin namespaces.