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:

  1. Translations live at i18n/<locale>.ftl — a Project Fluent file per locale, discovered from the project root at startup.
  2. A request-scoped Locale extractor resolves the active locale from the request, in a stable documented order.
  3. A t!() macro performs the actual key lookup with automatic fallback to the default locale and a rate-limited tracing::warn! on misses.

Status: ships in v0.4.x as opt-in via the i18n Cargo feature.


Quick start

1. Enable the feature flag

TOML
# Cargo.toml
[dependencies]
autumn-web = { version = "0.7", features = ["i18n"] }

2. Configure supported locales

TOML
# 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

Text
# i18n/en.ftl
welcome.title = Welcome to my blog
welcome.greeting = Hello, { $name }!
Text
# i18n/es.ftl
welcome.title = Bienvenido a mi blog
welcome.greeting = ¡Hola, { $name }!

4. Auto-load at app startup

Rust
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:

  1. ?locale=xx query parameter (explicit override; useful for testing and for "language switcher" links).
  2. Signed session cookie — the autumn_locale key inside the framework's HMAC-signed session, set via autumn_web::i18n::set_locale_in_session(session, locale).await. This is the recommended way to persist a switcher choice.
  3. Plain autumn_locale cookie — unsigned, set via autumn_web::i18n::set_locale_cookie(locale). Useful when sessions are not enabled.
  4. Accept-Language request header, with full RFC 7231 q-value negotiation (e.g. es-MX,es;q=0.9,en;q=0.7 matches es if only ["en", "es"] are supported).
  5. 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:

Rust
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:

Rust
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:

Rust
// 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:

Text
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_chain and returns the first hit.
  • If no fallback hits, it returns {$key} so the missing key is visible at render time, and a tracing::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>:

Text
# i18n/en.ftl
validation.email.email = Please enter a valid email address.
validation.password.length = Password must be at least 8 characters.
Rust
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:

Shell
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:

Shell
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:

  1. Add the feature flag to Cargo.toml and the [i18n] block to autumn.toml.
  2. Move every literal string in your templates into i18n/en.ftl under a stable key (we recommend <page>.<element>). This is a pure edit pass — no logic changes.
  3. Replace each literal in your handlers / templates with t!(locale, "key"). Any handler that needs translations adds a Locale parameter.
  4. Author additional locales (i18n/es.ftl, etc.) at your leisure. Missing keys fall back to default_locale so partial coverage works from day one.
  5. 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.

Rust
use autumn_web::i18n::Translated;

#[autumn_web::model(table = "posts")]
pub struct Post {
    #[id]
    pub id: i64,
    #[translatable]
    pub title: Translated,
    pub slug: String,
}
Rust
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:

  1. the request's active locale (resolved by the Locale extractor — URL prefix, ?locale=, cookie, Accept-Language, default), matched exactly;
  2. each entry of I18nConfig::resolved_fallback_chain() in order, matched exactly;
  3. absence — the single documented sentinel. resolve() returns None; Display renders 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:

Rust
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:

Rust
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:

Rust
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

Rust
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 '{}', which autumn migrate check classifies 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:

Shell
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 .ftl files in your editor.
  • ICU MessageFormat. Use Fluent's NUMBER and DATETIME built-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 replace validator.
  • 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