Skip to content

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:

node scripts/generate-irish-react-invariants.mjs --write
npm run check:i18n:irish-react

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

  1. Add or change the English source key first.
  2. 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:
  3. React JSON draft — OPENAI_API_KEY=... node scripts/translate-i18n-gaps.mjs --lang ga
  4. PHP lang/ga/*.php — author and review the Irish value directly; the Google-only PHP helper deliberately skips ga
  5. 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.