# Internationalisation · Martis docs

> Martis ships with three bundled locales (en, pt_PT, pt_BR) and exposes them in the user preferences panel. The system has three layers: PHP lang files (server strings), i18next resources (client strings), and the user preferences (per-user locale persistence).

Source: https://getmartis.com/docs/customization/i18n

# Internationalisation

> Adding, overriding and extending locales in Martis.

Martis ships with three bundled locales (`en`, `pt_PT`, `pt_BR`) and exposes them in the user preferences panel. The system has three layers: **PHP lang files** (server strings), **i18next resources** (client strings), and the **user preferences** (per-user locale persistence).

## How a locale reaches the UI

```
┌─────────────────┐
│  PHP __() keys  │  ← resources/lang/{locale}/*.php
└────────┬────────┘
         │ (TranslationsController /api/translations/{locale})
         ▼
┌─────────────────┐
│  i18next client │  ← loaded at boot, mutated by PreferencesMenu
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  React t() keys │
└─────────────────┘
 
┌─────────────────┐
│ User preference │  ← PUT /api/preferences { locale }
└────────┬────────┘
         │
         ▼
┌──────────────────────────────┐
│  martis.locale middleware    │
│  → app()->setLocale()         │
│  → __() returns right string  │
└──────────────────────────────┘
```

Two fetches happen when the user changes language in the preferences panel:

1. `PUT /api/preferences` persists the new locale.
2. `loadLocale()` re-fetches `/api/translations/{locale}`, merges the resources into i18next, then calls `queryClient.invalidateQueries()` so every server-side label (navigation, lenses, resource titles) re-fetches under the new locale.

**Order matters** — the PUT is awaited first so React Query invalidation reads the fresh preference from the DB.

### Server-side cache keys include the locale

Lens responses (`LensController`) and metric results (`Metric::resolve()`) are cached server-side for up to `cacheTtl()` seconds. Both cache keys now include `app()->getLocale()` so column headers, summary labels, and metric bucket names pick up the new locale immediately after a language switch — without waiting for the TTL to expire. Consumer-built controllers that wrap responses in `Cache::remember()` should follow the same pattern when the cached payload contains `__()`-translated text.

## Bundled locales

| Code | Label in UI | Ships with | Fallback |
| --- | --- | --- | --- |
| `en` | English | Package defaults | — |
| `pt_PT` | Português (PT) | Package defaults | `en` |
| `pt_BR` | Português (BR) | Package defaults | `en` |

Labels are read from `config('martis.preferences.locale_labels')`, merged with `MARTIS_UI_LOCALE_LABELS` (`code:label` pairs, the env entries winning; v2.10.0). `en_GB` has a label (`English (UK)`) but no bundled strings: it is offered only when listed in `MARTIS_UI_LOCALES`. The code itself is always what's persisted and sent to the API.

## Adding a new locale (e.g. Spanish)

### 1. Ship translation files

Create `lang/vendor/martis/es/*.php` in your app (`resources/lang/vendor/martis/es/` on pre-Laravel-11 layouts) mirroring the package structure. Martis UI strings are read **only** from the package and from `lang/vendor/martis/<locale>/`: a plain `lang/es/*.php` or `resources/lang/es/*.php` file is read only for the namespaces you list in `app_namespaces` (see below), never for the Martis UI keys.

```
lang/vendor/martis/es/
├── actions.php
├── auth.php
├── martis.php
├── messages.php
├── metrics.php
├── navigation.php
├── permissions.php   # role / permission labels (since v1.7.0)
├── profile.php
└── resources.php
```

The fastest path is copying `lang/vendor/martis/en/` (after `vendor:publish --tag=martis-lang`) to `lang/vendor/martis/es/` and translating values.

If you want to **override just a subset** of strings without shipping full files, publish the vendor lang and edit the files:

```
php artisan vendor:publish --tag=martis-lang
# edit lang/vendor/martis/es/*.php
```

Locale codes use an underscore (`pt_PT`, `pt_BR`): the translations endpoint strips every character outside `[a-zA-Z0-9_]`, so `pt-PT` would become `ptpt`.

Resolution order in `TranslationsController` (each layer applied with `array_replace_recursive`, so later writes win per key):

1. **Fallback chain** — every locale in `config('martis.locales.fallback_chain')` (default `['en']`) seeds the payload first.
2. **Package defaults** — `resources/lang/{locale}` shipped by Martis.
3. **App namespaces** — host-app files in `lang/<locale>/<ns>.php` for every namespace listed in `config('martis.locales.app_namespaces')`.
4. **Consumer publishes** — `lang/vendor/martis/{locale}`, then `resources/lang/vendor/martis/{locale}` (your app-level overrides). Wins over everything else.

`array_replace_recursive` merges per-key, so dropping a single-key file in `lang/vendor/martis/<locale>/actions.php` overrides only that key. The other keys keep flowing from the package defaults — no need to copy the whole namespace.

### 2. Register the locale

From `.env` (v2.10.0), without touching the published config:

```
MARTIS_UI_LOCALES=en,pt_PT,pt_BR,es
MARTIS_UI_LOCALE_LABELS="es:Español"
```

`MARTIS_UI_LOCALES` is a comma-separated list of codes (kept as given: `en_GB`, `pt_PT`); an invalid code throws, naming the variable. `MARTIS_UI_LOCALE_LABELS` is comma-separated `code:label` pairs (the first colon splits; a label cannot contain a comma) merged over the bundled labels; a locale with no label shows its code. Or edit the config, as an alternative:

```
// config/martis.php
 
'preferences' => [
    'locales' => ['en', 'pt_PT', 'pt_BR', 'es'],
    'locale_labels' => [
        'en' => 'English',
        'pt_PT' => 'Português (PT)',
        'pt_BR' => 'Português (BR)',
        'es' => 'Español',
    ],
],
```

### 3. Translate your own resources

The package only handles its own strings. For app resources (like a `ClientResource`), use `__('app-keys.clients')` in `label()`/`group()`/field labels and add the keys to your app's own lang files.

```
// app/Martis/Resources/ProductResource.php
 
public static function label(): string
{
    return __('my-app.products');  // your own lang namespace
}
```

### 4. Clear caches after adding

```
php artisan config:clear
php artisan cache:clear
```

The language dropdown picks up new entries on the next hard reload.

## ⭐ Extensibility differentials

Three knobs in `config/martis.php`'s `'locales'` section let consumer apps extend Martis's translation pipeline without forking the package. Defaults preserve the historical behaviour exactly — these are purely opt-in.

### 1. Per-key deep merge (always-on)

Consumer overrides published to `lang/vendor/martis/<locale>/` are merged into the package defaults at the **key level**, not the file level. You only ship the keys you want to change.

```
// resources/lang/vendor/martis/en/actions.php
return [
    'create' => 'Add new',  // overrides Martis's 'Create'
];
```

The rest of `actions.*` keeps flowing from the package's own `actions.php`. No need to copy the whole file, no risk of falling behind when the package adds new keys in a future release.

### 2. App namespaces auto-merged

By default, `/api/translations/<locale>` returns the package's own namespaces (`actions`, `auth`, `martis`, `messages`, `metrics`, `navigation`, `permissions`, `profile`, `resources`). To surface your own translation files in the same payload — without writing a parallel loader on the frontend — list them:

```
// config/martis.php
'locales' => [
    'app_namespaces' => ['app', 'billing'],
    // ...
],
```

```
// resources/lang/en/app.php
return [
    'tagline' => 'Run the business',
    'support_email' => 'support@example.com',
];
```

Now your React code can call `t('app:tagline')` (or `useTranslation('app')`) and get the value, with the same publish + deep-merge story as Martis's own namespaces. Names are validated against `/^[a-z][a-z0-9_]*$/i` (letters, digits and underscores, starting with a letter, either case) so a malformed entry can't escape the locale folder; a name that fails it is silently ignored, with no error or log, so check the spelling when a namespace does not load.

The `MARTIS_APP_LOCALE_NAMESPACES` env var accepts a comma-separated list as a shortcut: `MARTIS_APP_LOCALE_NAMESPACES=app,billing`.

> **Avoid name collisions with the package namespaces.** `app_namespaces` are applied after the package defaults with `array_replace_recursive`, so listing a name like `actions` (or any of the bundled namespaces) merges your file into the package namespace key by key: every key your file defines silently overrides the package string with the same key, and the other package keys stay. Prefer prefixed names — `app_actions`, `billing`, `crm` — so your strings stay scoped to the host app (a name must match `/^[a-z][a-z0-9_]*$/i`, so no dashes).

### 3. Configurable fallback chain

The default chain is `['en']`, matching the historical "fall back to English when missing" behaviour. Multi-step chains are useful for related locales:

```
// config/martis.php
'locales' => [
    'fallback_chain' => ['pt_BR', 'en'],
],
```

With this config, a `pt_PT` request seeds missing keys from `pt_BR` first, then falls back to `en` if `pt_BR` is also missing the key. The requested locale is automatically filtered out of the chain so it never seeds itself.

Equivalent env: `MARTIS_LOCALE_FALLBACK_CHAIN=pt_BR,en`.

## Locale formats

Laravel supports both flat (`es`) and underscore-variant (`pt_BR`, `es_MX`) codes. Martis preserves whatever code you put in config — no automatic case-folding.

The `TranslationsController` normalises inbound locale slugs: `pt_br` → `pt_BR`, `EN_US` → `en_US`. The normaliser standardises **casing only**; it does not reject unknown codes. A request for an unknown locale (`xyz`) returns a payload that contains only the fallback-chain seed (default `en`); if the fallback chain itself has no matches the response is an empty JSON object. There is no 404 — the React layer treats missing keys via i18next's own fallback rules.

## Number and date formatting

Dates, times, numbers and currency amounts format in the user's Martis locale, the language they picked in Martis, not in the browser's (v2.1.0). A user who picked `pt_PT` sees `27/09/2026` and `1234,5` on an `en-US` browser. The Martis code becomes the BCP 47 tag `Intl` expects (`pt_PT` becomes `pt-PT`), and a switch of language re-formats the page without a reload.

It covers the Date, DateTime and Currency displays, the Currency and Number inputs (which take the locale's decimal separator), the value, trend and progress metrics (their charts' axes and tooltips included), the partition metric's tooltips, the `aggregateVia()` tile, the menu count badges, the notification dates and the Browser sessions list.

The bundled `en` locale formats with US rules (`9/27/2026`, `1,234.5`), whatever the browser. An app whose users expect another English format lists a regional code such as `en_GB`: `MARTIS_UI_LOCALES=en_GB,pt_PT` and `MARTIS_DEFAULT_LOCALE=en_GB` (`en_GB` already has the label `English (UK)`). It translates through `en` (the `fallback_chain`) and formats as `en-GB` (`27/10/2026`) from the first paint, and `<html lang>` reads `en-GB`. The config route, adding `en_GB` to `martis.preferences.locales`, works the same.

## Changing locale at runtime

**From the user menu**: Preferences panel → Language dropdown. Persists to DB, refetches resources, invalidates React Query cache.

**From code** (an extension, with `usePreferences` and `loadLocale` from `@martis/runtime` since v1.38.0), the same two steps the Language dropdown runs:

```
import { loadLocale, usePreferences } from '@martis/runtime'
 
const { update } = usePreferences()
await update({ locale: 'es' })   // persist first: the refetches below read the saved locale
await loadLocale('es')           // fetch the bundle, switch i18next, refetch the queries
```

### Loading overlay

Because `loadLocale()` refetches the translation bundle and then invalidates every React Query cache under the new locale, a switch is not instant — on a slow connection the panel can sit in the old language for a moment. Martis shows a full-screen loading overlay (spinner + a translated "Switching language…" message) for the whole switch so the change is never silent. It is driven from `loadLocale()` itself, so it appears for the Preferences picker, the login/2FA pages, and the automatic post-login locale reconciliation alike, and always dismisses (a minimum-visible floor prevents flicker; a safety cap prevents a hung request from leaving it up). Customise the text via the `messages.switching_language` key in your locale files; there is no separate config knob.

## Pluralisation

Laravel's lang files support pluralised strings via the `|` separator (`'items' => '1 item|:count items'`) consumed by `trans_choice()`. The frontend goes through i18next, which uses `{{count}}` placeholders rather than the Laravel `:count` token. The translations endpoint rewrites every `:name` placeholder to `{{name}}` before it sends the payload (`TranslationsController::convertPlaceholders()`), `:count` included, so `'1 item|:count items'` reaches the React layer as `'1 item|{{count}} items'`. The `|` pluralisation syntax itself is not interpreted by i18next.

Two pragmatic patterns:

```
// resources/js/martis-extensions/...
import { useTranslation } from 'react-i18next'
 
const { t } = useTranslation('messages')
 
// (a) i18next interpolation on a single-form key — works out of the box.
t('items', { count: 5, defaultValue: '{{count}} items' })
 
// (b) When you want PHP-side `trans_choice()` semantics in the SPA,
//     register the i18next-ICU plugin in your consumer extension entry and rewrite the
//     value as ICU at edit time:
//     'items' => '{count, plural, one {1 item} other {{count} items}}'
```

The Martis layer does not preselect a pluralisation library — keep both `t('key', { count })` and explicit `t(count === 1 ? 'singular' : 'plural')` patterns in your toolbox, depending on how rich your locale set is.

## RTL (Right-to-Left) layout

The shell honours right-to-left layout for locales declared in `config('martis.locales.rtl_locales')` — the default ships Arabic, Persian, Hebrew, and Urdu (`ar`, `fa`, `he`, `ur`). When the active locale matches one of these codes the `<html>` element receives `dir="rtl"` (and `lang="<code>"`). Because the bundled CSS uses logical properties (`margin-inline-start`, `padding-inline-end`, `border-inline-end`, etc.), every margin / padding / border flips automatically — no separate stylesheet, no `[dir="rtl"]` selectors required.

The list is editable per environment via `MARTIS_RTL_LOCALES`:

```
# Comma-separated. Empty list disables RTL detection entirely.
MARTIS_RTL_LOCALES=ar,fa,he,ur,yi
```

Or directly in `config/martis.php`:

```
'locales' => [
    // ...
    'rtl_locales' => ['ar', 'fa', 'he', 'ur'],
],
```

The `applyDocumentDirection(locale)` helper the shell runs on every switch is on `@martis/runtime` (v1.38.0+) for consumer overrides that want to apply the same rule from a custom hook (e.g. an `<iframe>` overlay that reads the user's preference at mount time).

## Backend locale (server-rendered strings)

The `martis.locale` middleware runs inside the authenticated group and calls `app()->setLocale($userLocale)` before controllers run. Every `__()` call inside controllers (navigation labels, validation messages, resource titles) therefore returns the user's chosen locale.

For a user without a saved preference, the resolved locale is `config('martis.preferences.defaults.locale')` (`MARTIS_DEFAULT_LOCALE`, default `en`), not `APP_LOCALE`: set `MARTIS_DEFAULT_LOCALE` to change the default panel language. A default, a saved or a preset locale that is not in `martis.preferences.locales` (`MARTIS_UI_LOCALES`) is not applied: the resolver answers the default, or the first listed locale when the default is not listed either (v2.10.0). A missing or unreadable preferences table does not stop it: `PreferencesResolver` skips the saved row and answers the defaults, so the locale is `MARTIS_DEFAULT_LOCALE`. Only when the resolver itself throws does the request fall through with Laravel's current locale (`config('app.locale')`).

## Testing locale changes

```
# Test the resolver with a specific user
php artisan tinker
> $user = User::find(1);
> app(\Martis\Preferences\PreferencesResolver::class)->resolve(request()->setUserResolver(fn () => $user));
```

## Related

- [preferences.md](https://getmartis.com/docs/reference/preferences) — the broader preferences system.
- [installation-guide.md](https://getmartis.com/docs/getting-started/installation) — publishing lang files.

Customising generators

Agent guidelines + MCP
