Translations#
The system has two independent translation layers. Most of the confusion around multilingual behaviour comes from mixing them up.
| Layer | Where it lives | What it translates | When a text is missing |
|---|---|---|---|
| NEON | app/Locale/ |
interface texts — labels, buttons, messages | the bare key is printed, the page keeps working |
| Database | the *_texts tables |
content — names of pages, categories, products and their href |
either a fallback to the main language, or a 404 of the whole path |
The binding convention for NEON is in the skills/cs-translations/SKILL.md skill.
NEON: where the files are#
app/Locale/<module>/<locale>/<domain>.<LOCALE>.neon
│ │ │
│ │ └─ front | admin | scripts
│ └─ cs_cz | en_us | sk_sk
└─ base bazaar blog comcat discussion eshop general
invoicer results store system
Measured on 2026-08-15: 11 modules × 3 languages = 63 files, the same 21 in each
language (find app/Locale -name "*.neon" | wc -l). Of those, 27 are the front
domain, 30 admin, 3 scripts, and 3 files of the results module carry no domain
prefix.
How a message ID is formed#
The file bazaar/cs_cz/front.cs_CZ.neon:
→ the message ID front.bazaar.components.responseForm.popup.header: the domain
from the file name plus the tree path inside the NEON. Keys are composed as a tree,
never flat with dots in the name.
A form field needs label: as a sub-key, not a bare string
name: "Name" turns form.fields.name into a string, so
form.fields.name.label has nowhere to reach and the translator returns the bare
key. Always write form.fields.name.label: — even when there is a single sub-key
under it.
Where the domain comes from and who translates the template#
This is the most common source of "why did it not translate". Three places, three
different behaviours — verifiable in TranslatorInitTrait::setTranslatorDomain(),
Admin\BasePresenter::setupTemplateTranslator() and BaseComponent::setup():
| Where | Where the domain comes from | Does the template translate short keys |
|---|---|---|
| Front presenter | derived from the namespace — App\UI\Front\Blog\Presenters + ArticlePresenter → front.blog.presenters.article |
no — the template needs {translator $translatorDomain} |
| Admin presenter | manually from the $translatorDomain property on the presenter |
yes, the base class puts it into the template |
| Component (Front and Admin) | manually from the $translatorDomain property |
yes, setup() puts it into the template |
In a Front presenter the declared $translatorDomain is only documentation
startup() overwrites it with the value derived from the namespace. Move the
presenter to another namespace and you move its domain too — and all of its keys
stop existing. It shows up as raw keys printed on the page, not as an error.
{translator} must be INSIDE {block} / {snippet}, not before it
Outside a block Latte reports Unexpected end, expecting {/translator} — and it
reports it on a completely different line than the cause.
{translator} does not carry into an {include}
Every included section has to open it again. The parent template is translated, the section next to it prints keys — it looks like a missing translation.
In a COMPONENT template {translator} must NOT be used
The domain is already set there by BaseComponent::setup(). The extra macro ends
in an unterminated-translator error.
A global key is written absolutely, with two slashes
{_'//admin.system.form.status.active'} bypasses the domain and reaches the full
message ID. The administration uses it in 125 places for shared layout texts.
Procedure: adding a new text to the code#
- Find the right file — the module by whose code it is, the domain by the section
(
front/admin/scripts). - Check the key does not exist already.
messages.success,close,backand friends usually do; duplicating them means two different texts for the same thing later on. - Add the key as a tree next to logically related ones, the value in double quotes, indentation with spaces (NEON, not tabs).
- Add the same key to all three languages —
cs_cz,en_us,sk_sk. Fill an untranslated key with the Czech text at least; a missing key is worse than an untranslated one. - Use it in the code according to the context:
| Context | How it is written |
|---|---|
| Latte | {_'popup.header'} (with a domain), {_'//full.message.id'} (absolute) |
| Form | the bare key straight into addText('name', 'name.label') |
setOption('description', …) |
the key only, translated in Latte |
| PHP outside a form | $this->translator->translate($this->translatorDomain . '.popup.loadError') |
| A flash in a component | the bare key without the domain — {_$flash->message} adds it |
A flash before a redirect into another domain (Scripts → front) |
the finished translation as a string: $this->flashMessage($this->translator->translate($this->translatorDomain . '.…'), …) |
- Check the NEON is valid:
php -r "Nette\Neon\Neon::decode(file_get_contents('…'));"or simply load the page.
A form label passed in with the domain already applied gets prefixed twice
With useDomainTranslator() the translator adds the domain itself. $t('name.label')
in a label therefore produces
front.blog.presenters.contact.front.blog.presenters.contact.name.label — a key
that does not exist, so it prints raw. It looks like a missing translation, but the
problem is that it is there twice.
A flash printed by another presenter must not carry a key or an object
A link from an e-mail typically leads to a presenter that redirects elsewhere — the
newsletter confirmation from the scripts domain to the front homepage. The target
template then prints the message through {_$flash->message} with its own domain, so it
does not find the scripts key. An App\Core\Utils\Controls\FlashMessage object does not get through
at all — Translator::translate() takes only a string, even though the object is
Stringable. Send the finished text instead; thanks to returnOriginalMessage: true the
second translation returns it unchanged. Examples: NewsletterAddressPresenter (Scripts)
and RegistrationPresenter (Front).
setOption('description', …) never passes through the translator
Nette does not translate that value. Put a translated text in and Latte's {_…}
tries to translate a finished sentence — returning it unchanged only by accident.
The key belongs in PHP, the translation in the template.
Procedure: adding a new language#
- Create the language and localisation in the administration (System → Languages).
- NEON files: a new folder
app/Locale/<module>/<locale>/holding a copy of every file of that module with the new locale suffix (front.sk_SK.neon). - Register the short code in the map in
TranslatorInitTrait::LOCALE_MAPand inapp/Core/Middlewares/LocaleMiddleWare.php— both mapsk→sk_SK. - Delete
temp/cache/nette.configurator,nette.searchandtranslation. A new locale (a new file, not just a new key) requires a DI container rebuild. - The database layer starts with
cms_system_page_texts— without a row for the new language no named front page works, even if the categories under it are translated. It is only dozens of rows and it blocks everything else. - Only then the per-module
*_texts— e-shop, classifieds, company catalogue and blog categories. There are 84 such families in the system (find app/UI/Api -name "*Text.php" -path "*Entities*" | wc -l). - Walk through the main page types in the new language — landing page, listing, detail, form. Not just the homepage.
A short locale code passed to setLocale() returns CZECH
translator.neon has one shared fallback chain
[cs_CZ, cs, en_US, en, sk_SK, sk] for all languages, so cs_CZ always wins it.
Verified live: setLocale('en') returned Czech text even with a complete set of
English files. Hence the map in step 3 — without it the whole translation would be
invisible and nothing would point it out.
Without clearing temp/cache a new language appears unpredictably
Not as an error — partially. Some things are translated, some are not, depending on what happens to be regenerated. That takes hours to track down.
Procedure: a text is not being translated#
Cheapest first:
- Is a bare message ID printed? Then the key is missing from the NEON, or lives somewhere else than where you are looking. Read the printed key — it holds the whole path including the domain.
- Is the domain in the key twice? See the warning above — the text is translated in PHP and in Latte.
- Is it a presenter template? A Front presenter needs
{translator}, in every{include}too. - Is it a component template? There
{translator}must not be present. - Did the presenter's namespace change? The domain changed with it.
- Is the text from the database rather than from NEON? Names of categories, pages
and products are
*_textsrows — you will not find them in NEON. - After editing the files delete
temp/cache/translation/. Nette regenerates almost everything by itself; the translation catalogue it does not.
The database layer and its traps#
Content texts live in *_texts tables linked to a language. What happens when a
translation is missing depends on what that text is used for:
| Text | When it is missing |
|---|---|
a name (name, title) |
fallback to another language |
an href used in a route |
the route cannot compose the URL → No route for … |
a row in cms_system_page_texts |
404 of the whole path /<locale>/<anything> |
One untranslated category brings down the WHOLE foreign-language page
For a missing translation the route translator returns null,
RestrictedTranslatableRoute then fails to compose the URL and Nette reports
Invalid link: No route for … — which takes down the entire page, not just that
one link. AbstractTranslator::fromCacheWithLanguageFallback() solves it: with no
translation for the requested language, another one is used (the main language
first). It is wired up for e-shop, classifieds, company catalogue and blog
categories — in a new module you have to think of it yourself.
An empty translation BEATS the fallback
The fallback is written as a ?? chain, and that only skips null. An empty
string is a value, so a row with name = '' wins over the main language and the
element renders with no name at all. Keep the invariant "a row exists ⟺ it has a
non-empty name": do not create empty ones and delete the existing ones.
Content blocks (page sections, adverts in AdvertList) follow a stricter rule than names:
they do not fill in a foreign language. The translation is picked by
TranslationFallback::resolve($entity, $langId, $defaultLangId):
- the page language translation, if it exists and has
activeTranslation = 1, - otherwise the active translation of the localization's default language,
- otherwise
null— the block is not rendered.
getTranslation() does not drop an inactive translation
The entity returns a translation even with activeTranslation = 0. Code that picks a
block translation by itself through getTranslation() shows text the administrator
turned off. Take a new content block through TranslationFallback; the entity needs
getTranslation(int) (TranslationCollectionTrait), the translation
isActiveTranslation() (TranslationLanguageTrait).
A table can hold two translations of the same thing in one language
Six *_texts tables were missing their primary key, so
the database accepted duplicate rows — and ON DUPLICATE KEY UPDATE over them
silently inserted another instead of updating. Nothing guaranteed which one was
read. It usually looked like the previous pitfall: an empty row sitting next to
a filled one.
On an existing database, verify this using the
Database check chapter — validateClass will
not find a missing key, because it does not compare indexes.
A row can exist and still be in Czech
A copied, untranslated text looks finished and the untranslated filter will not find it. Coverage is therefore measured by content, not by row count.
The untranslated filter in the administration hides different things than the form
An empty filter does not mean "translated". It means "nothing matches what the filter looks for" — which is something else.
Database data in form options#
Select options loaded from the database (names of rates, types, languages) are already translated data, not keys. The form's domain translator would still look them up as keys: it prints them unchanged, but Tracy reports a missing translation for every option. There are two ways:
| Way | What happens |
|---|---|
wrap the option text in Contributte\Translation\Wrappers\NotTranslate |
the translator returns it unchanged (also through the prefixed translator); it works with the translator on or off, because it is Stringable |
setTranslator(null) on the control |
neither the options nor the prompt are translated; the label is still translated |
Pattern for wrapped options: InvoicerSelectDataTrait in the Invoicing administration.
After setTranslator(null) a key in setPrompt() is printed raw
Translate the prompt by hand, for example
->setPrompt($this->translator->translate($this->translatorDomain . '.form.fields.x.prompt')).
Otherwise the admin sees a bare key in the empty option of the select.
Admin language#
The admin does not read the user's language (User::$languageId) at all. The address decides,
in this order:
| Precedence | Source | Example |
|---|---|---|
| 1 | persistent parameter lang |
/admin/system-nastaveni/prehled?lang=en |
| 2 | language of the localization from the address prefix (loc; the default localization has no prefix) |
/en/admin/system-nastaveni/prehled |
| 3 | unknown localization | en |
The header switcher (LocaleSwitcher) offers active localizations whose language has at least
one file app/Locale/<module>/<locale>/admin.<LOCALE>.neon; several localizations of the same
language merge into one (the main one wins). The link goes to loc = <localization key> and clears
lang at the same time.
A link with lang overrides the prefix on every following page
lang is persistent, so it travels on in every link and the switcher then seems not to work.
Change the admin language through the prefix (linkParams()), not through lang.
Returning after a form save (resolveBackUrl()) does not treat the same page in another language
as the previous page (isSamePageIgnoringLocale()); otherwise "back" after switching the language
would lead to the edit page with the original prefix.
Procedure: adding an admin language
- Translate
admin.<LOCALE>.neonin at least one module; without the file the switcher does not offer the language. - A localization with this language must be active.
- Check switching on a record edit page and saving: the return leads to the list in the new language.
Where to look#
| I want | Where |
|---|---|
| to add or find an interface key | app/Locale/<module>/<locale>/<domain>.<LOCALE>.neon |
| the translator setup, fallback, whitelist | config/Shared/translator.neon |
| the short-code to locale mapping | app/Core/Middlewares/LocaleMiddleWare.php, app/Core/Traits/Shared/Presenters/TranslatorInitTrait.php |
| how a Front presenter's domain is derived | TranslatorInitTrait::setTranslatorDomain() |
| the domain in an administration template | app/Core/Base/Admin/BasePresenter.php → setupTemplateTranslator() |
| the domain in a component | app/Core/Base/BaseComponent.php → setup() |
| the translation fallback in routes | app/Core/Routers/Translators/AbstractTranslator.php |
| picking a content block translation | app/Core/Utils/Helpers/TranslationFallback.php |
| the texts of result codes | app/Locale/results/ + app/Core/Utils/Results/ResultCode.php |
| the admin language and switcher | app/UI/Admin/System/Models/Helpers/AdminLocaleOptions.php, app/UI/Admin/System/Components/LocaleSwitcherComponent/ |
| the binding convention | skills/cs-translations/SKILL.md |
Follow-up chapters: Routing · Forms · Cache · Code conventions