Skip to content
V
For developers
Architecture, conventions, the core and security
For developers / Translations

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:

bazaar:
    components:
        responseForm:
            popup:
                header: "Odpovědět na inzerát"

→ 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
{block content}
{translator $translatorDomain}
    <h1>{_'headers.list'}</h1>
{/translator}
{/block}

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#

  1. Find the right file — the module by whose code it is, the domain by the section (front / admin / scripts).
  2. Check the key does not exist already. messages.success, close, back and friends usually do; duplicating them means two different texts for the same thing later on.
  3. Add the key as a tree next to logically related ones, the value in double quotes, indentation with spaces (NEON, not tabs).
  4. 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.
  5. 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 . '.…'), …)
  1. 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#

  1. Create the language and localisation in the administration (System → Languages).
  2. 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).
  3. Register the short code in the map in TranslatorInitTrait::LOCALE_MAP and in app/Core/Middlewares/LocaleMiddleWare.php — both map sk → sk_SK.
  4. Delete temp/cache/nette.configurator, nette.search and translation. A new locale (a new file, not just a new key) requires a DI container rebuild.
  5. 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.
  6. 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).
  7. 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:

  1. 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.
  2. Is the domain in the key twice? See the warning above — the text is translated in PHP and in Latte.
  3. Is it a presenter template? A Front presenter needs {translator}, in every {include} too.
  4. Is it a component template? There {translator} must not be present.
  5. Did the presenter's namespace change? The domain changed with it.
  6. Is the text from the database rather than from NEON? Names of categories, pages and products are *_texts rows — you will not find them in NEON.
  7. 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):

  1. the page language translation, if it exists and has activeTranslation = 1,
  2. otherwise the active translation of the localization's default language,
  3. 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

  1. Translate admin.<LOCALE>.neon in at least one module; without the file the switcher does not offer the language.
  2. A localization with this language must be active.
  3. 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

Cookie settings