Internationalisation (i18n)¶
Last reviewed: 2026-08-17
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, generated for Web UK | lang/<lang>/govuk_alpha*.php, event_*.php, and safeguarding.php; generated output in web-uk/src/lib/localization/generated/ |
| 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:irish-safety |
Proves the Irish paths do not call Google-backed translation. |
npm run check:i18n:irish-react |
Rejects React Irish corruption, discouraged terminology, unclassified English-identical values, changed reviewed invariants, and stale invariant entries. |
npm --prefix web-uk run locales:audit-irish |
Rejects unreviewed English fallbacks, question-mark corruption, and terminology violations in the generated accessible-frontend Irish catalogue. |
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 ratchet exists to keep that debt moving down. Run the check for the current count rather than copying a fast-changing number into documentation.
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 translation policy¶
Never use Google Translate for Irish. Its Irish output is not approved for
release. translate-i18n-gaps.mjs permits ga only through the OpenAI path;
translate-php-lang-gaps.mjs is Google-only and therefore always skips ga.
OpenAI output is a draft, not a completed translation: review it in context and
rewrite it as natural Irish before marking the locale reviewed or approved.
If that review cannot be done, keep and report the English gap rather than
presenting machine Irish as finished.
Current Irish catalogue status (verified 2026-08-17)¶
Both maintained frontends are structurally current and have blocking Irish quality gates:
- React: all 145 locale namespaces match English with zero missing or extra
keys. The aggressive Irish gap scan reports zero candidates. Sixteen genuine
English UI labels found during the final review were replaced with authored
Irish. The 737 values that intentionally remain byte-identical to English are
names, identifiers, functional examples, formats, units, or reviewed shared
technical terms; each is pinned by exact key, value, and reason in
scripts/irish-react-reviewed-invariants.json. - Accessible frontend (
web-uk): the generated Irish catalogue contains 39 namespaces and 9,348 strings with zero missing or extra keys. Its 104 values identical to English are all reviewed invariants; the Irish audit reports zero unreviewed fallbacks, question-mark mismatches, or terminology violations.
These figures establish catalogue completeness and enforce the reviewed exceptions. They do not turn automated checks into native-speaker certification of every sentence. Independent in-context linguistic review is still welcome and any correction should be treated as a quality improvement, not as evidence that Google Translate should be introduced.
The React invariant manifest is deliberately exhaustive. Do not add a broad allowlist entry to hide a new English-identical value. Review the value in its UI context first; translate it when it is real copy. Only if it is genuinely locale-invariant should you regenerate the manifest with:
Use this terminology consistently on member-facing surfaces. Prefer a plain sentence over forcing one glossary term into a context where it does not fit.
| English concept | Preferred Irish | Avoid |
|---|---|---|
| timebank / timebanking | banc ama / baincéireacht ama |
translating the organisation as a financial bank |
| time credit | creidmheas ama |
creidiúint ama |
| exchange | malartú |
switching between malartú and malartán for the same workflow |
| federation network | líonra comhpháirtíochta or líonra na bpobal comhpháirtíochta |
cónascadh, which suggests merging systems |
| federated / cross-community | idirphobail or ar fud an líonra comhpháirtíochta |
a literal form of "merged" |
| checkout | describe the action: Téigh chuig an íocaíocht or Críochnaigh an t-ordú |
Seiceáil amach |
| safeguarding | cosaint agus sábháilteacht, shortened to cosaint where the context is clear |
wording that implies only system security |
| vetted member | ball a ndearnadh grinnfhiosrúchán air |
ball grinnfhiosrúcháin |
| read-only | le léamh amháin or ní féidir é a athrú |
inléite amháin |
| impersonate a user | logáil isteach mar úsáideoir eile |
aithris a dhéanamh ar úsáideoir |
| burnout | ídiú |
dóiteán, which means a fire |
| guardian consent | toiliú caomhnóra |
wording that turns a recorded arrangement into account authority |
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. For locales other than Irish, use the configured translation
helper and then review its output. For Irish, follow the policy and glossary
above; do not add
--google: - React JSON draft —
OPENAI_API_KEY=... node scripts/translate-i18n-gaps.mjs --lang ga - PHP
lang/ga/*.php— author and review the Irish value directly; the Google-only PHP helper deliberately skipsga - Other PHP locales —
node scripts/translate-php-lang-gaps.mjs --google --namespace <file>.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, including npm run check:i18n:irish-safety, 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.