Autumn ships an opt-in i18n module that gives Spring Boot / Rails / Phoenix migrants the localization story they expect, with convention over configuration and a small, well-defined surface.
The module is gated behind the i18n feature flag, so apps that don't
enable it pay zero compile cost and incur zero runtime cost. It is built
around three ideas:
- Translations live at
i18n/<locale>.ftl— a Project Fluent file per locale, discovered from the project root at startup. - A request-scoped
Localeextractor resolves the active locale from the request, in a stable documented order. - A
t!()macro performs the actual key lookup with automatic fallback to the default locale and a rate-limitedtracing::warn!on misses.
Status: ships in
v0.4.xas opt-in via thei18nCargo feature.
Quick start
1. Enable the feature flag
# Cargo.toml
[dependencies]
autumn-web = { version = "0.7", features = ["i18n"] }
2. Configure supported locales
# autumn.toml
[i18n]
default_locale = "en"
supported_locales = ["en", "es"]
# Optional — when omitted, falls back to [default_locale].
fallback_chain = ["en"]
# Optional — defaults to "i18n".
dir = "i18n"
3. Author a translation file per locale
# i18n/en.ftl
welcome.title = Welcome to my blog
welcome.greeting = Hello, { $name }!
# i18n/es.ftl
welcome.title = Bienvenido a mi blog
welcome.greeting = ¡Hola, { $name }!
4. Auto-load at app startup
use autumn_web::prelude::*;
#[get("/")]
async fn index(locale: Locale) -> Markup {
html! {
h1 { (t!(locale, "welcome.title")) }
p { (t!(locale, "welcome.greeting", name = "Ada")) }
}
}
#[autumn_web::main]
async fn main() {
autumn_web::app()
.i18n_auto() // discovers `i18n/` dir from the [i18n] block
.routes(routes![index])
.run()
.await;
}
That's the whole flow. Open the app at /?locale=es to see Spanish, or
set Accept-Language: es in your browser.
File convention
Every .ftl file inside the configured dir (default: i18n/) is
loaded at startup, keyed by its filename stem. So i18n/en.ftl becomes
the en locale, i18n/pt-BR.ftl becomes pt-BR, etc.
The default locale's file is mandatory: if it's missing,
Bundle::load_from_dir returns
LoadError::MissingDefaultLocale and .i18n_auto() panics with the
typed error message. This is the spec's "fail fast" rule — a
half-localized app is worse than a clearly broken one.
Files that are not the default locale are optional; missing locales just won't be served, and the negotiation step will fall through to the next-best match.
Resolution order
The Locale extractor walks the request in this order, returning the
first locale that matches the configured supported_locales:
?locale=xxquery parameter (explicit override; useful for testing and for "language switcher" links).- Signed session cookie — the
autumn_localekey inside the framework's HMAC-signed session, set viaautumn_web::i18n::set_locale_in_session(session, locale).await. This is the recommended way to persist a switcher choice. - Plain
autumn_localecookie — unsigned, set viaautumn_web::i18n::set_locale_cookie(locale). Useful when sessions are not enabled. Accept-Languagerequest header, with full RFC 7231 q-value negotiation (e.g.es-MX,es;q=0.9,en;q=0.7matchesesif only["en", "es"]are supported).- The configured
default_locale.
The order is stable. Applications can rely on it. If steps 1–4 produce a locale that is not in the supported list, the extractor falls through to the next step rather than serving an unsupported locale.
Implementing a locale switcher
The simplest switcher is a pair of ?locale= links:
html! {
a href="?locale=en" { "English" }
a href="?locale=es" { "Español" }
}
The recommended way to persist the choice across navigations is the
framework's signed session cookie via set_locale_in_session. The
session cookie is HMAC-signed by the framework, so a hostile client
cannot forge it:
use autumn_web::i18n::set_locale_in_session;
#[post("/locale/{locale}")]
async fn switch(session: Session, Path(locale): Path<String>) -> impl IntoResponse {
set_locale_in_session(&session, &locale).await;
Redirect::to("/")
}
For apps that don't use the session subsystem, the unsigned
autumn_locale cookie via set_locale_cookie(locale) is the fallback —
note it lives after the session in the resolution order, so a
session-set locale always wins.
The t!() macro
t! is a proc-macro (lives in autumn_macros, re-exported as
autumn_web::t and from autumn_web::prelude when the i18n feature is
on). Two forms:
// Without args:
t!(locale, "welcome.title")
// With named args (Project Fluent's `{ $name }` placeable syntax):
t!(locale, "welcome.greeting", name = "Ada")
Compile-time key validation
At expansion time the proc-macro reads
$CARGO_MANIFEST_DIR/i18n/<default_locale>.ftl (where
<default_locale> is the value of the AUTUMN_I18N_DEFAULT_LOCALE
env var, defaulting to "en"; AUTUMN_I18N_FILE overrides the path).
If the requested key is not present, the build fails:
error: i18n key `welcome.tite` is not defined in the default locale bundle
hint: did you mean `welcome.title`?
--> src/routes/index.rs:12:24
|
12 | t!(locale, "welcome.tite")
| ^^^^^^^^^^^^^^
If the file does not exist (e.g. a brand-new app that just enabled the
feature flag), the macro falls back to a runtime-only call — the build
succeeds and the runtime {$key} marker surfaces the missing key. As
soon as you author i18n/en.ftl, the next build picks up the
compile-time check automatically.
Runtime behaviour
- If the key is missing in the requested locale, the bundle walks the
configured
fallback_chainand returns the first hit. - If no fallback hits, it returns
{$key}so the missing key is visible at render time, and atracing::warn!(rate-limited per(locale, key)pair) is emitted. - Unknown placeables (
{ $name }with no matching arg) are left literally in the output for the same reason: silent empty strings hide bugs.
Localizing validator error messages
This is a documented pattern, not new public API. Map a
validator::ValidationErrors to a localized error map by using the
field name plus a convention like validation.<field>.<code>:
# i18n/en.ftl
validation.email.email = Please enter a valid email address.
validation.password.length = Password must be at least 8 characters.
use validator::ValidationErrors;
fn localize_errors(errors: &ValidationErrors, locale: &Locale) -> Vec<(String, String)> {
let mut out = Vec::new();
for (field, field_errors) in errors.field_errors() {
for err in field_errors {
let key = format!("validation.{field}.{}", err.code);
out.push((field.to_string(), t!(locale, &key)));
}
}
out
}
If a code is missing from the .ftl, the {$key} marker makes the
omission obvious during testing. We deliberately do not fork or replace
validator — its API is the API.
Scaffolding a new project
autumn new can scaffold the i18n module for you with a flag — it is
off by default so the baseline new-project experience stays minimal:
autumn new my-app --with-i18n
This creates i18n/en.ftl with a stub welcome.title /
welcome.greeting pair, adds the [i18n] block to autumn.toml,
enables the i18n feature on the autumn-web dependency, and wires
.i18n_auto() into main.rs. Drop additional i18n/<locale>.ftl
files at your leisure.
Translatable CRUD from the generator
autumn generate scaffold … --i18n emits views that are already wired to
this stack: every page title, heading, button, link, and field label is a
t!(locale, "key") lookup, each view handler takes the Locale
extractor, and the referenced keys are back-filled into i18n/en.ftl
with their English values. Running it in a project that has no i18n setup
yet also does the three wiring steps above (feature flag, [i18n] block,
.i18n_auto()), so a fresh autumn new app becomes translatable in one
command:
autumn generate scaffold Post title:String body:Text --i18n
Shared chrome (common.create, common.save, common.back, …) is
written once and reused by every scaffolded resource; each resource adds
only its own nouns (post.name, post.field.title, …). Re-running the
generator never rewrites a value you have translated. See
Translatable views in the
generators guide for the full key map and the flag's limits.
Migrating from monolingual
The migration is bounded by the size of your translation file, not by the number of files you have to touch:
- Add the feature flag to
Cargo.tomland the[i18n]block toautumn.toml. - Move every literal string in your templates into
i18n/en.ftlunder a stable key (we recommend<page>.<element>). This is a pure edit pass — no logic changes. - Replace each literal in your handlers / templates with
t!(locale, "key"). Any handler that needs translations adds aLocaleparameter. - Author additional locales (
i18n/es.ftl, etc.) at your leisure. Missing keys fall back todefault_localeso partial coverage works from day one. - Add a locale switcher somewhere visible.
There is no schema change, no per-handler wiring. The shape of the translation file is the only authored surface.
Translatable model fields
Everything above localizes the chrome. #[translatable] localizes the
content: a #[model] column that stores an independent value per locale
tag and resolves against the request's active locale — with no locale argument
anywhere in the handler.
use autumn_web::i18n::Translated;
#[autumn_web::model(table = "posts")]
pub struct Post {
#[id]
pub id: i64,
#[translatable]
pub title: Translated,
pub slug: String,
}
use autumn_web::prelude::*;
#[get("/posts/{id}")]
async fn show(Path(id): Path<i64>, repo: PgPostRepository) -> AutumnResult<Markup> {
let post = repo.find_by_id(id).await?.ok_or_else(AutumnError::not_found_msg)?;
// No `Locale` parameter. `Display` resolves the active locale.
Ok(html! { h1 { (post.title) } })
}
Under Accept-Language: es that renders the Spanish title; under
Accept-Language: fr (untranslated) it falls back through the same
fallback_chain UI strings walk. One mental model, not two.
Resolution
Translated::resolve mirrors the t! lookup exactly:
- the request's active locale (resolved by the
Localeextractor — URL prefix,?locale=, cookie,Accept-Language, default), matched exactly; - each entry of
I18nConfig::resolved_fallback_chain()in order, matched exactly; - absence — the single documented sentinel.
resolve()returnsNone;Displayrenders the empty string. Never a panic, never a 500.
The active locale is published by a tower layer that Autumn installs
automatically alongside the translation bundle, so it is in scope for the whole
handler — including nested /{locale}/… routes. Outside a request (a job
worker, a scheduled task, a test) resolution falls back to the process-wide
chain installed at boot instead of panicking.
Resolution also survives a streaming response: the layer wraps the response
body, so an SSE stream or Body::from_stream download that renders
(post.title) per frame still resolves to the visitor's locale rather than the
default.
To scope a locale explicitly — a digest mailer looping over subscribers, a background render, a job worker — wrap the work that renders:
for subscriber in subscribers {
// Wrap the RENDER, not the enqueue: `deliver_later` on an already-built
// message ships whatever was rendered outside the scope.
autumn_web::i18n::with_locale(subscriber.locale.clone(), async {
deliver_later_weekly_digest(&state, &subscriber).await
})
.await?;
}
A tokio::spawned task does not inherit the scope (task-locals never
cross a spawn); pass the tag in and re-enter with with_locale inside the new
task.
Writing translations
Translated is the whole set of translations, so the ordinary
read-modify-write round trip changes one locale and leaves the rest intact:
let mut post = repo.find_by_id(id).await?.expect("post");
post.set_title("es", "Hola mundo"); // `en` is untouched
repo.update(post.id, &UpdatePost { title: Patch::Set(post.title), ..Default::default() }).await?;
Serializing is lossless — it emits the whole map — which is what keeps record
version history and durable commit-hook payloads from destroying the other
locales on replay. Deserializing is the exact inverse: a map, and only a
map. A bare string is deliberately refused, because assigning a Translated
replaces the whole container — so PUT /api/posts/1 with
{"title": "Hola"} would delete every other language from a body that reads
like it sets one field. Write {"title": {"es": "Hola"}} and the replacement is
at least visible in the payload.
When you are applying a partial update — a per-locale edit form, an API patch that names one language — merge it into the loaded container instead of assigning:
let mut post = repo.find_by_id(id).await?.expect("post");
post.title.merge_from(&incoming); // every locale `incoming` omits survives
repo.update(post.id, &UpdatePost { title: Patch::Set(post.title), ..Default::default() }).await?;
Knowing what still needs translating
post.available_locales("title"); // ["en", "es"]
post.is_translated("title", "fr"); // false → render a "needs translation" badge
Post::translatable_fields(); // ["title"]
post.title_locales(); // per-field sibling of `available_locales`
Storage
The column stays TEXT on both Postgres and SQLite and holds a JSON object:
{"en":"Hello","es":"Hola"} (Mobility's "container" backend). Two consequences
worth knowing:
- The migration is an ordinary
ADD COLUMN … TEXT NOT NULL DEFAULT '{}', whichautumn migrate checkclassifies as safe — translatable storage adds no new operation type and no backfill job. - A stored value that is not a JSON object of strings is read as the default
locale's translation. An existing plain-text column can therefore be declared
#[translatable]with no data migration. The flip side: a column that already holds a JSON object of strings is read as a container. Its values are preserved, but they are keyed by whatever that object used, so the field resolves to nothing and renders empty until the app rewrites it. Declare#[translatable]only on columns that hold human-readable text. - The container's keys are yours: every key you can write round-trips, for any string. Autumn deliberately does not gate them on locale-tag shape — a key the writer accepted but the reader rejected would make the whole column decode as legacy text, hiding every translation and escaping the JSON on the next save.
Generating one
The field DSL carries a {translatable} modifier:
autumn generate model Post 'title:String{translatable}' slug:slug
That emits the #[translatable] attribute, the Translated field type, the
Text entry in schema.rs, and the migration above.
generate scaffold refuses a {translatable} column: its CRUD form binds
one plain text input to the whole container, so saving would replace every
other locale's value. Generate the model and write the per-locale edit view
yourself — a translation-editing UI is out of scope here (see below).
Restrictions
#[translatable] is String-shaped, non-null, and opt-in per field. It is
refused — at compile time by the macro, and at generate time by the DSL and by
the matching --unique / --index / --searchable / --shard-key flags — in
combination with #[encrypted] (one opaque envelope has no per-locale
structure), #[searchable] (a tsvector over the container would index locale
tags and JSON punctuation), unique/indexed (the index compares whole
containers), the {min}/{max}/{email}/{url} validation fan-out (those
rules measure a string, not a set of them), #[normalize], #[state_machine],
#[id], #[lock_version], and #[position]. #[serde(rename)] and
#[diesel(column_name)] are refused too: the column is registered for
framework surfaces under its Rust field name, which is also the key
available_locales("title") matches on. Each refusal names the reason and the
alternative.
Two smaller things worth knowing. A model with a translatable field gains the
inherent methods translated, localized, available_locales,
is_translated and translatable_fields, so an impl of your own defining a
method by one of those names on the same type will collide. And a hand-written
form_for text input bound to a translatable column renders blank: the
column's serialized value is a map, not a scalar, so build the per-locale
inputs explicitly (that is the same edit view the scaffold refusal above is
telling you to write).
Out of scope
The framework deliberately does not ship:
- Translation management tooling integrations (Crowdin, Lokalise, Phrase).
Author
.ftlfiles in your editor. - ICU MessageFormat. Use Fluent's
NUMBERandDATETIMEbuilt-ins for number / date formatting. - Right-to-left layout helpers in
autumn-web/ui. The CSS / layout side stays the user's problem; the framework only owns text resolution. - A localized version of every framework-emitted error page. Track separately if demand emerges.
- A reimplementation of
validator's message system. We document the integration pattern above; we do not fork or replacevalidator. - Hot-reloading translation files in
autumn dev. Nice-to-have, not blocking. - A translation-workflow UI for
#[translatable]content: per-locale edit forms, approval/review states, per-translation versioning, or machine translation. The framework owns storage and resolution; the editing screen is the app's.
If your app needs richer plural / gender rules, author them in Fluent itself — the format already supports them.
See also
autumn_web::i18n— module-level rustdoc with full API.examples/blog— visit/greetfor the working end-to-end demo (English + Spanish).- Project Fluent — upstream syntax reference.