Localization in Suprnova is one module with four faces: message catalogs
on the server, validation errors that arrive already translated, the
same catalog bytes handed to the browser, and locale-aware number,
date, and list formatting. The message format is
Fluent - Mozilla's .ftl, the one Firefox
ships - and the whole subsystem is on by default behind the
localization feature.
The shortest possible tour. Write a catalog:
# lang/en/app.ftl
welcome = Welcome to { $app }!
# lang/es/app.ftl
welcome = ¡Bienvenido a { $app }!
Use it from a handler:
use ;
pub async
A request with Accept-Language: es gets the Spanish string, because
LocaleMiddleware resolved the locale before your handler ran. Nothing
else in the handler changes - no locale parameter threaded through, no
&Translator in the signature.
Why localization
Three reasons this is a framework concern rather than a crate you pick:
- Validation messages are the framework's strings, not yours. "The
email field is required." is emitted deep inside
Rule::passes, far from any code you own. Unless the framework carries a translation seam, a Spanish app ships English validation errors - or you wrap every rule by hand. Suprnova's built-in rules return keyed messages; you translate them by dropping a.ftlfile in, and never touch the rules. - The browser needs the same strings. An Inertia app renders half
its text in Rust and half in Svelte/React/Vue. Two translation systems
means two file formats, two review workflows, and two chances for the
same sentence to drift. Suprnova serves the exact catalog the server
resolved from
/_suprnova/lang/<locale>.ftl, and the starter kits parse it with@fluent/bundle- one set of files, one source of truth. - Plurals and formats are CLDR data, not string concatenation.
English has two plural categories, Russian and Polish four, Arabic six.
A number is
1,234.56inen-USand1.234,56inde-DE. Fluent selects on CLDR plural categories and ICU4X does the formatting, so neither is something you hand-roll per locale.
Turning the feature off (--no-default-features) is supported: the
localization module doesn't compile, and validation renders its embedded
English fallback strings. Nothing else changes shape.
File layout
Catalogs live under lang/, one directory per locale:
myapp/
├── lang/
│ ├── en/
│ │ ├── app.ftl
│ │ └── validation.ftl
│ └── es/
│ ├── app.ftl
│ └── validation.ftl
├── src/
└── frontend/
The rules:
- A directory name is a BCP-47 locale -
en,en-GB,pt-BR,zh-Hans. A directory whose name doesn't parse is skipped with awarn!rather than failing boot. - Every
.ftlin a locale directory merges into one catalog, in sorted filename order. Split by feature (auth.ftl,billing.ftl,emails.ftl) as much as you like - message ids are global within the locale, soauth.ftlandbilling.ftlmust not define the same id. - The framework's own English validation catalog loads first, into
every locale's bundle. Your files load over it, and a later definition
wins. That is the whole override mechanism: define
validation-mininlang/es/validation.ftland the Spanish bundle uses yours. - The root is
lang_path()-<APP_BASE_PATH>/lang. SetAPP_BASE_PATHwhen the binary runs from somewhere other than the project root (a systemd unit, a container with a differentWorkingDirectory), or calluse_lang_path("…")to move only thelangdirectory. See Environment Variables. - A missing
lang/directory is not an error. A fresh app must boot, so the translator comes up with the embedded English catalog and nothing else. A malformed.ftlis a different story: parse errors fail boot, naming the file and what the parser objected to, because a silently half-loaded catalog is worse than a stopped process. - In
localanddevelopment, catalogs hot-reload. Each request statslang/and reparses only when something actually changed, so editing a.ftlshows up on the next refresh. Production never re-stats; catalogs are read once at boot.
FTL in five minutes
Fluent is a small format. This section is everything you need for a typical app.
Messages are id = value pairs. Ids are kebab-case by convention
(the framework's own are), values run to end of line, and indented
continuation lines are joined:
# A comment. Attached to the message below it.
sign-in = Sign in
password-hint =
Use at least 12 characters. A passphrase of a few
ordinary words beats a short string of symbols.
Arguments are { $name } placeables. You supply them at call time;
missing arguments are an error, not an empty string (Lang::get then
falls through its chain - see The Lang facade):
greeting = Hello, { $name }!
invoice-line = { $qty } × { $item }
Terms start with -, are private to the catalog, and exist so a
brand name or a repeated phrase lives in one place:
-product-name = Suprnova
about = About { -product-name }
footer = © 2026 { -product-name }. All rights reserved.
Selectors are Fluent's conditional. The selector value is matched
against variant keys; exactly one variant is marked default with *:
cart-summary =
{ $count ->
[0] Your cart is empty.
[one] One item in your cart.
*[other] { $count } items in your cart.
}
[0] matches the literal number zero. [one] and [other] are CLDR
plural categories, resolved for the bundle's locale - which is where
Fluent earns its place. English has two categories; Russian has four,
and a Russian translator writes all four without you changing a line of
Rust:
# lang/ru/app.ftl
unread-messages =
{ $count ->
[one] У вас { $count } непрочитанное сообщение.
[few] У вас { $count } непрочитанных сообщения.
[many] У вас { $count } непрочитанных сообщений.
*[other] У вас { $count } непрочитанного сообщения.
}
CLDR assigns 1, 21, 31 to one; 2–4, 22–24 to few;
0, 5–20, 25–30 to many; and fractions to other. The same
__!("unread-messages", count: 22) call renders correctly in English,
Russian, Polish, and Arabic, because the category selection is data, not
code.
Always put the * on other. It is the one category CLDR defines
for every locale, so it is the only variant guaranteed to exist - and
the default is what an unmatched selector value falls through to,
including any non-integer count. Marking *[many] (or any other
category) as the default sends fractions to text written for whole
numbers.
Pass counts as numbers.
__!("unread-messages", count: 3)sends a JSON number and selects a plural category.count: "3"sends a string, which can only match a literal variant key - it will land on your*[other]default. This is the one FTL trap worth memorising.
Functions are called inside placeables. Two are registered:
NUMBER() (Fluent's builtin) and DATETIME() (Suprnova's):
score = Your score is { NUMBER($points) } out of { NUMBER($total) }.
published = Published { DATETIME($when, dateStyle: "medium") }
See Locale-aware formatting for both.
One deliberate limitation: Suprnova resolves flat message values
only. Fluent's attribute syntax (login .placeholder = …) parses but is
not addressable through Lang::get, so keep one id per string:
login-placeholder, not login.placeholder. Ids are a flat namespace
per locale - prefix them (auth-login-title, billing-invoice-due)
rather than reaching for a hierarchy the resolver doesn't have.
The Lang facade
Lang is the server-side entry point. Every method reads the current
locale, which the middleware bound for this request.
| Method | Returns | Notes |
|---|---|---|
Lang::get(key) |
String |
Infallible. Runs the fallback chain, then returns the key itself |
Lang::get_with(key, args) |
String |
Same, with arguments |
Lang::try_get(key) |
Result<String, FrameworkError> |
Errors instead of degrading |
Lang::try_get_with(key, args) |
Result<String, FrameworkError> |
Same, with arguments |
Lang::has(key) |
bool |
Whether the key resolves for the current locale, or anywhere along its fallback chain |
Lang::locale() |
Locale |
The current locale |
Lang::set_locale(locale) |
() |
Change it for the rest of this request |
Lang::available_locales() |
Vec<Locale> |
Every locale with a loaded catalog |
use ;
let subject = get;
let mut args = new;
args.insert;
args.insert;
let body = get_with;
if has
let locales: = available_locales
.iter
.map
.collect;
TranslateArgs is an ordered map of String to serde_json::Value,
both re-exported from the crate root. Fluent arguments are strings and
numbers; other JSON shapes are stringified.
The fallback chain
Lang::get never fails, and it never returns an empty string. In order:
- The current locale's catalog.
- Its configured fallback parents (see Fallback
chains), walked transitively, if any are
configured -
pt-PTbeforept-BRbefore whateverpt-BRitself names as a parent, and so on. - The fallback locale's catalog (
APP_FALLBACK_LOCALE, defaulten), unless it already appeared earlier in this chain. - The key itself, plus one
tracing::warn!per missing(locale, key)pair - once, not once per request, so a missing key in a hot path doesn't drown your logs.
Step 4 is why a missing translation renders checkout-submit in the
button instead of a blank button: a visibly wrong string is a bug report
waiting to happen, while an empty one is a mystery.
When you'd rather know than degrade, use the try_* siblings. They run
steps 1 through 3 and return Err instead of doing step 4:
use Lang;
// A missing key here means a broken email - fail the job, don't send
// a message with a raw key in the subject line.
let subject = try_get?;
The __! macro
__! is the Laravel-muscle-memory shorthand. With no arguments it calls
Lang::get; with named arguments it builds a TranslateArgs and calls
Lang::get_with:
use __;
let plain = __!;
let greeted = __!;
let counted = __!;
Argument values are anything that converts into a
serde_json::Value - &str, String, integers, floats, bool. The
macro is exported at the crate root, so suprnova::__!("welcome-back")
works without the import when you'd rather not bring __ into scope.
Fallback chains
APP_FALLBACK_LOCALE is one global net under every locale. Sometimes
that's not enough: European Portuguese and Brazilian Portuguese share
nearly everything and diverge on a handful of words
(ficheiro/arquivo, utilizador/usuário, tu/você), and
maintaining two complete catalogs means every new string has to be
written twice. A fallback parent lets pt-PT inherit from pt-BR
before pt-BR falls further back to the global fallback_locale - so
lang/pt-PT/ only has to hold the strings that are actually different.
Configuring parents
One environment variable, comma-separated child=parent pairs:
APP_LOCALE_PARENTS=pt-PT=pt-BR
Or the builder, one call per pair, chainable:
use ;
Both paths feed the same map (LocalizationConfig::parents), and both
are validated at boot, not at request time:
- A pair with no
=, or an empty child or parent, is a malformedAPP_LOCALE_PARENTSentry - boot fails naming the bad segment. - A locale invalid as BCP-47 on either side of the pair fails the same way.
- Naming the same child twice is ambiguous config, not last-wins - boot fails naming the duplicate child.
- A cycle fails boot. The error spells out the cycle: two locales
naming each other (
pt-PT=pt-BR,pt-BR=pt-PT) produces`pt-PT` -> `pt-BR` -> `pt-PT`. A locale naming itself as its own parent (pt-PT=pt-PT) is the same case in miniature -`pt-PT` -> `pt-PT`. (Two code paths raise this error: parsingAPP_LOCALE_PARENTS- so any app whose config goes throughLocalizationConfig::from_env()fails at config load - andFluentTranslator's catalog load, which catches a cyclic map built programmatically with.parent(...). Only an app that builds its config entirely by hand and binds its own customTranslatorinbootstrap_fnskips both;Lang's walk is guarded independently and still terminates safely there, it just won't get the loud boot-time error.)
The builder's .parent(child, parent) is last-write-wins for a repeated
child - a later call overriding an earlier one is just a later
override, not the ambiguous-input case APP_LOCALE_PARENTS guards
against.
Resolution order
A chain can be more than one hop long: pt-PT names pt-BR as its
parent, and pt-BR can in turn name a parent of its own.
Lang::get / try_get / get_with / try_get_with / has all walk
the whole thing, current locale first:
- The current locale's catalog.
- Its configured parent, then that locale's configured parent, transitively, until a locale with no configured parent is reached.
- The global
fallback_locale(APP_FALLBACK_LOCALE), unless it already appeared earlier in the chain - including the common case where it's just the current locale itself (theen/endefault).
Lang::get / Lang::get_with fall through to the key itself if
nothing in the chain resolves it, exactly as The fallback
chain describes; Lang::try_get /
Lang::try_get_with return Err, and Lang::has returns false. This
walk runs inside the Lang facade itself, so it works for any
Translator - the bundled FluentTranslator, or a driver you write.
A runnable example
myapp/
├── lang/
│ ├── pt-BR/
│ │ ├── app.ftl
│ │ └── validation.ftl
│ └── pt-PT/
│ └── app.ftl
├── src/
└── frontend/
# lang/pt-BR/app.ftl
welcome = Bem-vindo ao { $app }!
file-label = Arquivo
# lang/pt-PT/app.ftl
file-label = Ficheiro
use __;
// A request that resolved to `pt-PT`.
assert_eq!; // pt-PT's own override
assert_eq!;
lang/pt-PT/ never defines welcome - it doesn't need to. file-label
is a genuine one-word difference between the two catalogs, so it's the
only id that gets a file.
Served catalogs are flattened
The /_suprnova/lang/pt-PT.ftl endpoint (see The catalog
endpoint) never asks the browser to know that
pt-BR exists. FluentTranslator pre-merges the whole chain into one
resource per locale at load time - the embedded framework catalog at
the bottom for en/en-* locales, then the configured parent chain,
then the locale's own files - and serves that, already flattened.
Fetch pt-PT.ftl and the response carries welcome and file-label
both, in one request, with no client-side chain logic. ?v=<hash>
still names one immutable resource; the hash simply now covers strings
pulled in from pt-BR too.
Flattening covers configured parents only - it never reaches past
them to fallback_locale. pt-PT's served catalog includes pt-BR's
strings because pt-BR is a configured parent; it does not include
en's strings just because en happens to be the global fallback.
LocaleShare's fallback field always names the terminal
fallback_locale, unaffected by any of this - it tells the frontend
where Lang's facade-level walk would eventually land, not what's
already in the file it just fetched.
Delta-file merge rules
A child catalog merges over its parent at the Fluent AST level, not by textual concatenation and not by whole-message shadowing. The override unit is the pattern, so:
- A child value replaces the parent's value, in the parent's position in the file.
- A child entry with attributes but no value keeps the parent's
value. Retranslating
.placeholderdoesn't require repeating the message's own text. - Attributes merge by name. A same-named child attribute replaces
the parent's, in place; a child-only attribute appends after the
parent's own. Attributes the child doesn't mention survive from the
parent - overriding a message's value never silently drops its
.placeholderor.aria-label. - Select expressions replace whole, never variant-by-variant. A selector's variants are keyed to one locale's CLDR plural categories; because those categories are locale-dependent, splicing one variant from the parent and another from the child could produce a selector with no single locale's grammar behind it. A child that overrides a selector at all must supply every variant it wants.
- Comments on an overridden entry stay the parent's. The comment documents the id, and the override unit is the pattern, not the comment.
- Child-only entries append at the end, in the child's own order,
comments included - an id
pt-BRnever defined is not an "override" of anything.
Terms (-brand) follow the identical rule, with one narrowing: a
term's value is never optional in Fluent syntax, so the
"attributes-but-no-value keeps the parent's value" case above applies
to messages only - a child term always supplies a value, and that
value always wins. Attribute merge-by-name, whole-pattern replacement
for the value, and parent-wins comments all apply to terms exactly as
to messages. Terms are tracked in their own namespace - overriding
-brand can never shadow a message also named brand.
Why Suprnova diverges
Laravel 13 has exactly one fallback: the single global fallback_locale
config value, consulted when the current locale's array is missing a
key. There is no concept of one locale inheriting from a sibling locale -
pt_PT.php and pt_BR.php are two unrelated arrays, and a pt_PT
app either duplicates everything pt_BR already has translated, or
ships without it.
Suprnova's parent chains are the Rust-side extension: an intermediate
step between "this locale" and "the global fallback," configured
per-locale rather than once globally. The tradeoff we didn't want to
make is pushing that complexity onto the browser - a chain-aware
frontend would need to fetch pt-PT.ftl, discover it's incomplete,
fetch pt-BR.ftl too, and merge them client-side in JavaScript, using
rules that would have to exactly match the server's. Flattening at load
time instead means the served catalog is always one complete,
self-contained file - the same contract the frontend already had before
parent chains existed, so @fluent/bundle and the kit wrappers needed
zero changes to support this feature.
Locale detection
LocaleMiddleware resolves one locale per request and binds it for the
duration of the handler. The chain is config-driven and first hit
wins:
- Session - the
localekey in the session, if session middleware ran and the value names an available locale. This is where "user picked Español in settings" lives. - Cookie - the
localecookie. Survives logout, so a language choice made before signing in isn't lost. Accept-Language- negotiated againstavailable_locales()withfluent-langneg, honouring q-values.fr-CH, es;q=0.8, en;q=0.5against catalogsen+esresolves toes.APP_LOCALE- the configured default, when nothing above hit.
A candidate that doesn't parse, or names a locale with no catalog, is
skipped, not rejected. A user with a stale locale=zz cookie sees
the default language, not a 500. A garbage Accept-Language header does
the same. Attacker-controlled input reaches this chain on every request;
it must never be able to do more than pick a language.
Wire it up in bootstrap.rs, after the session middleware, since
step 1 reads the session:
use Arc;
use ;
pub async
LocaleMiddleware::from_env() reads LocalizationConfig::from_env();
LocaleMiddleware::new(config) takes one you built yourself. A
scaffolded app has both lines already.
Changing the locale mid-request
Lang::set_locale is Laravel's App::setLocale - it rewrites the
current request's locale from that point on:
use session_mut;
use ;
/// The user just switched languages in a settings form.
Note the two halves: set_locale affects this request (so the
redirect's flash message is already in Spanish), and the session write
is what the detection chain reads on the next one.
Outside a request
Console commands, queue workers, and scheduled tasks have no request and
no middleware. There, Lang::set_locale writes a process-global
override that Lang::locale() consults before falling back to
APP_LOCALE:
use ;
use crateDigest;
use crateUser;
pub async
Because that override is process-wide rather than task-local, set it at
the top of each unit of work as above - don't rely on it being unchanged
across an .await that another task could interleave with.
Configuration
Three environment variables. APP_LOCALE and APP_FALLBACK_LOCALE both
default to en; APP_LOCALE_PARENTS defaults to empty - no per-locale
overrides, only fallback_locale applies:
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
# APP_LOCALE_PARENTS=pt-PT=pt-BR
Everything else is code, on LocalizationConfig. It registers like every
other typed config - in your config::register_all, which runs before
boot:
// src/config/mod.rs
use ;
default_locale/fallback_locale- overrideAPP_LOCALEandAPP_FALLBACK_LOCALEfrom code. A malformed value in either place fails boot rather than silently becomingen.use_isolating- Unicode isolation marks around interpolations. Off by default; turn it on when you ship an RTL locale.detection- the chain, in order. DroppingDetect::Cookiemeans a language choice only lives in the session; droppingDetect::Headermeans the browser's preference is ignored entirely.session_key/cookie_name- rename the two lookups.parents- per-locale fallback parents (child -> parent), walked beforefallback_localewhen a key is missing from the child's catalog; same shape asAPP_LOCALE_PARENTS. Add one with.parent(child, parent)- chainable, last write wins for a repeated child. See Fallback chains for the full contract (boot-time validation, resolution order, served-catalog flattening).
Boot binds an Arc<dyn Translator> in the container. If your app has
already bound one, the framework leaves it alone - which is how you
substitute a translator of your own without forking anything:
// src/bootstrap.rs
use Arc;
use ;
pub async
Translator is the extension seam: translate, has,
available_locales, catalog, reload. One driver ships
(FluentTranslator), and a new backend is a new driver - not a fork of
the surface.
Translated validation messages
Every built-in rule returns a keyed message: a catalog key, the
arguments the message needs, and an English fallback. Translation
happens once, at the serialization boundary - ValidationErrors::to_json
and the Inertia error bag - never inside the rule. Rules stay pure, and
the whole subsystem compiles out.
The keys follow one convention:
| Shape | Example | Used for |
|---|---|---|
validation-<rule> |
validation-min, validation-required-if |
One per built-in rule, kebab-cased |
field-<name> |
field-email |
A human name for a field |
validation-invalid-data |
- | The top-level "The given data was invalid." banner |
To translate them, define the ids you care about in any .ftl file
under the target locale:
# lang/es/validation.ftl
validation-invalid-data = Los datos proporcionados no son válidos.
validation-required = El campo { $field } es obligatorio.
validation-email = El campo { $field } debe ser una dirección de correo válida.
validation-min = El campo { $field } debe tener al menos { $min } caracteres.
validation-confirmed = La confirmación del campo { $field } no coincide.
$field is always available. Every rule's own parameters are passed
under the names they carry in the framework's English catalog -
$min, $max, $other, $value - and
framework/src/localization/catalogs/en/validation.ftl is the canonical
list of ids and arguments. Copy the ids you need out of it; you never
have to override all of them.
Overriding works per locale and per key. Defining validation-min in
lang/en/validation.ftl replaces the framework's English wording for
that one rule and leaves the rest alone.
Field names
Interpolating a raw column name produces "The email_address field is
required." The field-<name> convention fixes that:
# lang/en/validation.ftl
field-email_address = email address
field-dob = date of birth
Before rendering, the translator looks up field-<name> for the current
locale. A hit is passed as $field; a miss falls back to the field name
with underscores turned into spaces. So the file above is only needed
for the names that humanize badly.
Custom rules
Rule::passes returns Result<(), ValidationMessage>. A keyed message
participates in translation:
use ;
;
# lang/en/validation.ftl
validation-starts-with = The { $field } field must start with { $prefix }.
A plain string still works, and is the right answer for a message that will only ever exist in one language:
Err // keyless: rendered verbatim
Keyless messages skip translation entirely, which is what keeps existing custom rules compiling and behaving exactly as before.
The derive flow
#[derive(Validate)] errors are keyed too. The validator crate's
error code becomes validation-<code> with underscores turned into
dashes, and every param the validator attaches becomes a message
argument - with two reserved exceptions, value and other, which are
always dropped. Both carry a field's actual value rather than
metadata about the rule: value is the echoed input under test, and
other (set by must_match, the canonical password-confirmation rule)
is the sibling field's value. Neither is ever handed to the catalog, so
no .ftl override - however it phrases validation-must-match - can
interpolate a submitted secret into a 422 response body. So a
#[validate(email)] failure resolves validation-email like the
hand-written rule does, and a locale that translates one translates
both.
The frontend
The browser gets the same bytes the server resolved. Nothing is re-translated, re-exported, or kept in sync by hand.
The catalog endpoint
GET /_suprnova/lang/es.ftl → 200 text/plain, ETag: "<hash>"
GET /_suprnova/lang/es.ftl?v=<hash> → 200 + Cache-Control: public,
max-age=31536000, immutable
GET /_suprnova/lang/es.ftl → 304 when If-None-Match matches
GET /_suprnova/lang/zz.ftl → 404 (no such catalog)
The body is the merged catalog for that locale - framework messages
first, then its configured fallback parent chain if any (see Fallback
chains), then your files in load order. ETag is the content hash. Ask
for a specific hash with ?v= and the response is immutable-cacheable
forever, because that URL can only ever mean one thing; ask without it
and you get revalidation instead. Like /_suprnova/health, the path is
exempt from the middleware chain: it must answer before a locale has
been resolved, and it carries no user data.
The shared prop
LocaleShare is an InertiaSharedData the framework ships. Registered
in bootstrap.rs (see Locale detection), it adds
one prop to every Inertia page:
catalog is null when no translator is bound - the share never fails
a page render.
The kit wrappers
Each starter kit ships a ~100-line wrapper that reads that prop, fetches
the catalog once, builds a @fluent/bundle bundle, and exposes t().
Call initLang once in your Inertia entry point (scaffolded apps
already do):
// frontend/src/main.ts
import { createInertiaApp } from '@inertiajs/svelte'
import { mount } from 'svelte'
import { initLang } from './lib/lang.svelte'
createInertiaApp({
resolve: (name) => { /* … unchanged … */ },
async setup({ el, App, props }) {
await initLang(props.initialPage)
mount(App, { target: el!, props })
},
})
Then, in components:
<!-- Svelte 5 -->
<script lang="ts">
import { t, currentLocale } from '../lib/lang.svelte'
</script>
<h1>{t('welcome', { app: 'Suprnova' })}</h1>
<p>{currentLocale()}</p>
// React 19
import { useLang } from '../lib/lang'
export default function Home() {
const { t, locale } = useLang()
return <h1>{t('welcome', { app: 'Suprnova' })}</h1>
}
<!-- Vue 3.5 -->
<script setup lang="ts">
import { useLang } from '../lib/lang'
const { t, locale } = useLang()
</script>
<template>
<h1>{{ t('welcome', { app: 'Suprnova' }) }}</h1>
</template>
Number and date formatting on the client uses the browser's built-in
Intl - no ICU data is shipped to the browser.
Typed message keys
suprnova generate-types parses lang/<default locale>/*.ftl and emits
a union of every message id alongside the page-props types:
// frontend/src/types/lang-keys.ts
// Generated by `suprnova generate-types` - do not edit.
export type MessageKey =
| "validation-min"
| "welcome"
The wrappers type t(key: MessageKey, …), so this is the same promise
as inertia-props.ts: rename a message
in Rust, regenerate, and the TypeScript compiler points at every call
site that still uses the old id. suprnova serve watches lang/
alongside src/, so the file regenerates as you edit catalogs.
A project with no lang/ directory and no message ids gets no
file - an app that isn't localized sees no new artifact appear.
Locale-aware formatting
Seven functions on Lang, all ICU4X-backed, all reading the current
locale, all with try_* siblings that return
Result<String, FrameworkError> instead of degrading:
use NaiveDate;
use ;
let dt = from_ymd_opt
.and_then
.expect;
number; // en-US → 1,234,567.89
// de-DE → 1.234.567,89
currency; // en-US → $19.99
date; // en-US → August 1, 2026
time; // en-US → 2:30 PM
datetime;
list; // → Ada, Grace, and Alan
relative; // → 3 days ago
The style enums: DateStyle { Full, Long, Medium, Short },
TimeStyle { Medium, Short }, ListStyle { And, Or, Unit },
RelativeUnit { Second, Minute, Hour, Day, Week, Month, Year }.
Lang::relative takes a signed amount - negative is the past
("3 days ago"), positive the future ("in 3 days").
Exact output comes from the CLDR data baked into ICU4X and can change across an ICU upgrade, particularly for dates and currency. In your own tests, assert on shape and locale-distinctness (
de != en, contains2026) rather than on exact bytes.
Formatting inside a message
Two functions are callable from FTL:
order-total = Your total is { NUMBER($amount, maximumFractionDigits: 2) }.
published = Published { DATETIME($when, dateStyle: "medium", timeStyle: "short") }
use __;
let line = __!;
NUMBER() is Fluent's builtin, registered explicitly, and gives you
fraction-digit control inside the message. DATETIME() is Suprnova's:
$value accepts an ISO-8601 string or epoch milliseconds, and
dateStyle / timeStyle take the same names as the Rust enums, lower
case. A value it cannot parse passes through verbatim with a warn! -
a Fluent function cannot return an error, and a rendered page with one
odd-looking date beats a 500.
When you want ICU4X's full formatting rather than what a Fluent function exposes, format in Rust and pass the finished string in:
use ;
let total = __!;
Testing your translations
Two helpers do the work: use_lang_path points the loader at a fixture
directory, and scope_locale pins the current locale for the duration
of a future.
The hermetic form - build a translator over a fixture directory and bind it in a test-scoped container - is what the framework's own tests use, because it touches no process-global state and survives parallel test execution:
use Arc;
use TestContainer;
use ;
async
use_lang_path is the right tool when the test boots the real
application and you want the whole app pointed at fixtures:
use use_lang_path;
async
It writes a process-global path override, so treat it as a per-binary setting rather than something two parallel tests can disagree about.
Detection itself - the session/cookie/Accept-Language chain - is worth
testing through the real pipeline rather than by calling the middleware
directly, because the interesting cases are about header parsing and
about which source wins. Mount a route whose handler returns
__!("welcome"), register LocaleMiddleware in the
MiddlewareRegistry, and drive it with the loopback harness from
HTTP Tests, sending Accept-Language: fr, es;q=0.8 and
asserting on the Spanish body. The cases worth pinning: a header
negotiates, a cookie beats a header, an unavailable locale is skipped
rather than erroring, and a malformed header still returns 200.
See Testing for TestContainer::scope when your test runs
on a multi-threaded runtime - the thread-local fake() guard above does
not survive a future migrating between workers.
Why Suprnova diverges
FTL files, not PHP arrays. Laravel has two formats - nested arrays
in lang/en/messages.php, plus flat JSON in lang/en.json for
string-keyed translations - and neither is loadable by a browser, nor
expresses plural selection in the file: that lives in trans_choice's
pipe-and-range convention inside the string. Fluent gives us one format that the server and
the client both parse, which is what makes "the frontend shows the same
string the validator produced" a property of the design rather than a
convention you maintain. It costs you a new syntax to learn (this
chapter is most of it) and a tooling change: Poedit can't edit .ftl,
while Crowdin, Weblate, Lokalise, and Pontoon can. It also costs
dotted namespacing - trans('messages.welcome') has no equivalent,
because ids are a flat namespace per locale. Prefix instead.
No trans_choice. Laravel selects a plural form with pipe-separated
strings and explicit ranges:
Now count to 22 in Polish. CLDR puts 22 in the few category - 22 pliki - but [5,*] swallows it and produces 22 plików. The same
break happens at 32, 42, 102, and in Russian, Arabic, Czech, Lithuanian,
and Welsh, each in its own places. Integer ranges cannot express plural
rules, because plural rules are not about ranges; they're about the last
digit, the last two digits, and in some languages whether the value is
an integer at all. Fluent selects on the CLDR category directly, so
$count is an ordinary argument and the translator - the person who
knows the language - writes all four of Polish's categories:
files =
{ $count ->
[one] { $count } plik
[few] { $count } pliki
[many] { $count } plików
*[other] { $count } pliku
}
one is 1; few is 2–4, 22–24, 32–34, 102–104; many is 0, 5–21,
25–31; other catches the fractions (1,5 pliku) and carries the
default marker, per the rule above.
Laravel's rangeless form (plik|pliki|plików) does better - it consults
a per-language index and picks the nth segment - but that index is a
hand-maintained table rather than CLDR data, it offers Polish three
segments where CLDR defines four categories, the segments are positional
with no category names to review, and it can only ever select on the
count.
Which is the second benefit, falling out for free: a Fluent selector can switch on any argument, not just a count. Gender, plan tier, and connection state select the same way, and none of them needed a new facade method.
Isolation marks are off by default. Fluent normally wraps every
interpolation in U+2068 (FIRST STRONG ISOLATE) and U+2069 (POP
DIRECTIONAL ISOLATE), so that a right-to-left value embedded in a
left-to-right sentence renders in the right order. Correct - and
invisible, which means every assert_eq!("Hello Ada", …) in an
English-only app fails with two characters nobody can see in the diff.
We default them off and make turning them on one call:
let config = from_env?.use_isolating;
Turn them on when you ship an RTL locale - Arabic, Hebrew, Persian, Urdu - or any locale where user-supplied values mix scripts inside a sentence. Then update your assertions to compare against strings that carry the marks, or strip them in the assertion helper. The default optimises for the common case; the correct case is one line away and this paragraph is the reminder to take it.
Next
- Validation - rules, the
validate!macro, and whereValidationMessagecomes from - TypeScript Types -
generate-types,inertia-props.ts, andlang-keys.ts - Middleware - ordering
LocaleMiddlewareagainst the rest of the global chain - Session - the store the first detection step reads
- Environment Variables -
APP_LOCALE,APP_FALLBACK_LOCALE,APP_LOCALE_PARENTS,APP_BASE_PATH - Testing -
TestContainer,#[suprnova_test], and hermetic DI overrides
