Localization
Locales follow BCP 47: a lowercase language subtag, optionally followed by
an uppercase region subtag — en, pt, pt-BR, es.
Translating
from craft.support import __
__("login") # active locale
__("login", "pt-BR") # a specific locale
__("welcome_{name}", "pt-BR", name="Ana")
In templates:
<h1>{{ __('recent_posts') }}</h1>
A key with no translation returns the key itself, so a missing string is visible rather than blank.
The fallback chain
A regional locale inherits from its base language, then from the application fallback:
pt-BR → pt → en
from craft.support import locale_chain
locale_chain("pt-BR", "en") # ['pt-BR', 'pt', 'en']
locale_chain("pt", "en") # ['pt', 'en']
This means a regional locale only needs the keys where it genuinely differs.
Ask for pt-BR and get pt when the Brazilian variant has nothing to add.
Configure the ends of the chain:
APP_LOCALE=pt-BR
APP_FALLBACK_LOCALE=en
Casing
Tags are normalised, so lookups are case-insensitive and underscore-tolerant:
from craft.support import normalize_locale
normalize_locale("PT-br") # 'pt-BR'
normalize_locale("pt_BR") # 'pt-BR'
normalize_locale("EN") # 'en'
Store the canonical form; the helper accepts the rest.
Where translations live
Translations are dynamically resolved per locale in the chain with a database-first priority:
1. The translations table (Database — Primary Source of Truth) — enables real-time updates via admin panels, migrations, and seeders without code redeployment:
| key | locale | value |
|---|---|---|
| login | pt-BR | Entrar |
| login | pt | Iniciar sessão |
Seed them in database/seeders/TranslationSeeder.py:
python dev.py db seed
2. Configuration (Fallback) — for bootstrap values or environments before database initialization:
# config/lang.py
pt_BR = {"greeting": "Olá"}
Seed them in database/seeders/TranslationSeeder.py:
TRANSLATIONS = {
"en": {"login": "Log In", "dashboard": "Dashboard"},
"pt": {"login": "Iniciar sessão", "dashboard": "Painel de Controlo"},
"pt-BR": {"login": "Entrar", "dashboard": "Painel de Controle"},
"es": {"login": "Iniciar sesión", "dashboard": "Panel de Control"},
}
python dev.py db seed
pt and pt-BR are not the same
They are different copy, not a relabel. Filing Brazilian text under the generic
pt tag means European Portuguese users get the wrong words.
| Key | pt | pt-BR |
|---|---|---|
| dashboard | Painel de Controlo | Painel de Controle |
| download | Transferir | Baixar |
| register | Registar | Criar conta |
| login | Iniciar sessão | Entrar |
| logout | Terminar sessão | Sair |
The same applies to compliance wording: pt follows GDPR terminology
("Encarregado de Proteção de Dados"), pt-BR follows LGPD ("encarregado pelo
tratamento de dados pessoais").
Semantic keys
resources/lang/catalog.json holds a larger catalog with dotted, semantic keys
across all four locales:
{
"en": { "auth.login.failed": "We couldn't sign you in." },
"pt-BR": { "auth.login.failed": "Não foi possível entrar." }
}
Semantic keys survive copy changes: auth.login.failed still makes sense when
the wording changes, where a key named after the text does not.
The catalog is not wired into the seeder — the shipped views still use flat keys
(__('login')). Adopting it is a migration, not an addition.
Placeholders
Keep interpolation in the catalog rather than concatenating strings:
__("welcome_{name}", "pt-BR", name="Ana") # "Olá, Ana!"
Placeholders must match across locales — a key with {min} in English needs
{min} everywhere, or the value is dropped in that language.
Adding a locale
- Add its entries to
TRANSLATIONSin the seeder. - Re-seed:
python dev.py db seed. - Set
APP_LOCALE, or pass the locale per call.
Only the keys that differ from the base language are needed — the chain covers the rest.