Documentation menu
HomeDocsCustomizationInternationalisation

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

CodeLabel in UIShips withFallback
enEnglishPackage defaults—
pt_PTPortuguês (PT)Package defaultsen
pt_BRPortuguês (BR)Package defaultsen

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));