Skip to content

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

  1. Add or change the English source key first.
  2. Add the same key to every other locale file (check-i18n-drift confirms structural parity).
  3. Run the i18n checks above and confirm the baseline does not regress.
  4. 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.