Internationalisation (i18n)¶
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, and the platform ships 11 languages: English, Irish (Gaeilge), German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese, and Arabic (with full right-to-left support).
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/en/*.json, lang/<lang>/*.php |
| 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.
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-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. Adding keys to lang/en/*.php requires adding translated counterparts to every other lang/<locale>/*.php in the same commit (node scripts/check-php-lang-parity.mjs).
Right-to-left (Arabic)¶
Arabic (ar) renders right-to-left. The frontend flips layout direction automatically; the accessible frontend applies dir="rtl". Irish (ga) uses the OpenAI translation path when an API key is available, because DeepL does not support Irish.
Adding or changing a string¶
- Add or change the English source key first.
- Add the same key to every other locale file (
check-i18n-driftconfirms structural parity). - Run the i18n checks above and confirm the baseline does not regress.
- For non-English locale changes, declare
Translation Status:andTranslation 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.