Digging Deeper
Internationalization
Wheels 4.0 does not ship i18n primitives — no t() helper, no locales/ convention, no built-in locale resolver. This page shows you the manual pattern you can build in your own app: detect the visitor’s locale, load translation files, expose a t() view helper, format dates and numbers with Lucee’s built-in locale functions, and localize your routes.
You’ll learn:
- How to detect a request’s locale from session, URL, headers, or a default
- How to load per-locale translation files at app init and look up keys with a custom
t()helper - How to handle simple pluralization and interpolated values
- How to format dates, numbers, and currency with Lucee’s
LSDateFormat,LSNumberFormat, andLSCurrencyFormat - How to localize routes so URLs look like
/fr/postsand/en/posts
Detect the locale
Section titled “Detect the locale”Locale detection is a request-level concern, which makes it a natural fit for middleware. Resolve it once on the way in, stash it on request.locale, and every downstream helper can read it without re-parsing headers.
The conventional precedence is:
session.locale— the visitor’s saved preference (set when they click a language switcher)params.locale— a URL segment like/fr/posts(see route localization below)Accept-Language— the browser’s default- Your app default (
"en")
component implements="wheels.middleware.MiddlewareInterface" { public any function handle(required any request, required any next) { local.locale = arguments.request.session.locale ?: ""; if (!Len(local.locale)) { local.locale = arguments.request.params.locale ?: ""; } if (!Len(local.locale)) { local.locale = Left(arguments.request.cgi.http_accept_language ?: "en", 2); } arguments.request.locale = Len(local.locale) ? local.locale : "en"; return arguments.next(arguments.request); }}Register the middleware globally in config/settings.cfm so every request is resolved before any controller runs:
set(middleware = [ new app.middleware.LocaleResolver()]);Downstream code reads request.locale — no more header parsing.
Load translation files
Section titled “Load translation files”Keep translations out of CFCs. Per-locale JSON files are the path of least pain: diffable, easy to hand off to a translation service, trivial to hot-reload in dev.
config/ locales/ en.json fr.json de.jsonA flat key/value schema keeps lookups cheap. Dotted keys give you pseudo-namespacing without nesting:
{ "welcome.title": "Welcome back", "welcome.subtitle": "Here's what's new since your last visit", "posts.count.one": "1 post", "posts.count.other": "{count} posts", "forms.submit": "Save changes"}Load every locale at app init into application scope so the files are read once, not per request:
application.wheels.translations = {};for (var locale in ["en", "fr", "de"]) { local.path = ExpandPath("config/locales/#locale#.json"); application.wheels.translations[locale] = DeserializeJSON(FileRead(local.path));}Reload the app (wheels reload, or ?reload=true&password=... in dev) after editing a locale file.
Write a custom t() helper
Section titled “Write a custom t() helper”Drop a view helper into app/views/helpers.cfm so every template can call t("welcome.title") the same way Rails templates call t(:welcome_title).
public string function t(required string key, struct values={}) { var locale = request.locale ?: "en"; var translations = application.wheels.translations[locale] ?: application.wheels.translations["en"]; var value = translations[arguments.key] ?: arguments.key; for (var placeholder in arguments.values) { value = Replace(value, "{#placeholder#}", arguments.values[placeholder], "all"); } return value;}The helper does three things: look up the requested locale (falling back to English if that locale wasn’t loaded), find the key (falling back to the key itself if missing — a visible “untranslated” signal), and interpolate {placeholder} tokens from the values struct.
Use it anywhere a view runs:
<h1>#t("welcome.title")#</h1><p>#t("welcome.subtitle")#</p><button>#t("forms.submit")#</button>Pluralization
Section titled “Pluralization”English has two plural forms (one / other). Most Western European languages are the same. Arabic has six. Russian has three with non-trivial rules. Polish is its own adventure.
For the easy case, branch on count and look up a key variant:
public string function tPlural(required string key, required numeric count, struct values={}) { var form = arguments.count == 1 ? "one" : "other"; arguments.values.count = arguments.count; return t(arguments.key & "." & form, arguments.values);}With the JSON above, tPlural("posts.count", 1) returns "1 post" and tPlural("posts.count", 5) returns "5 posts".
For languages with three or more plural forms, the one/other branch is wrong. Reach for a CLDR-aware library of your choice rather than hand-rolling the rules — getting Russian plurals right alone is a weekend you don’t want to spend.
Format dates, numbers, and currency
Section titled “Format dates, numbers, and currency”Lucee’s LS* functions are the locale-aware versions of the standard formatters. They read the current locale from GetLocale() and format accordingly — no Wheels wrapper needed.
public void function applyRequestLocale() { var localeMap = {en: "en_US", fr: "fr_FR", de: "de_DE"}; var lucee = localeMap[request.locale ?: "en"] ?: "en_US"; SetLocale(lucee);}Call applyRequestLocale() from a beforeAction in your base controller, or from a second middleware that runs after LocaleResolver. Once set, the LS* functions do the rest:
#LSDateFormat(post.publishedAt, "long")# <!--- mardi 20 avril 2026 --->#LSNumberFormat(product.price, ",.99")# <!--- 1 234,56 --->#LSCurrencyFormat(invoice.total, "local")# <!--- 49,99 € --->#LSParseDateTime(params.startsAt)# <!--- parses using current locale --->These are Lucee built-ins — nothing Wheels-specific. The two-letter short codes (en, fr) you use for translation files don’t match Lucee’s locale strings (en_US, fr_FR), which is why the localeMap above bridges them.
Localize your routes
Section titled “Localize your routes”If you want /en/posts and /fr/posts as sibling URL trees, wrap your resources in a :locale scope:
<cfscript>mapper() .scope(path="/:locale", callback=function(map) { map.resources("posts"); map.resources("users"); map.root(to="home##index", method="get"); }) .wildcard().end();</cfscript>Every request now has params.locale populated. Your LocaleResolver middleware already picks that up as step 2 in the precedence chain, so the rest of the stack stays unchanged. Add a root-level redirect to your default locale if bare / requests need to land on /en/.
Make sure locale-scoped routes come before .wildcard() — see how route ordering works.
Forms and validation
Section titled “Forms and validation”Form labels translate cleanly — they’re plain strings, so #t("forms.submit")# works everywhere a label goes.
Validation error messages are trickier. Wheels generates them with errorMessagesFor() using a hard-coded English template. The pragmatic fixes:
- Pass
messageon each validation:validatesPresenceOf(property="email", message=t("errors.email.required")). The string is resolved at validation time using the current request’s locale. - Override
errorMessagesFor()in a view helper to look each message up throught()before rendering.
Both are workarounds — they leak through whenever core Wheels generates a message you didn’t catch. Flag the gap for when first-class i18n lands, and don’t build an elaborate abstraction on top of it.