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 namespaceApp\UI\Front\Blog\Presenters + ArticlePresenterfront.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 languagescs_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
  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.

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 sksk_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.

A table can hold two translations of the same thing in one language

Six *_texts tables were missing their primary key (2026-09-03 review), 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.

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.phpsetupTemplateTranslator()
the domain in a component app/Core/Base/BaseComponent.phpsetup()
the translation fallback in routes app/Core/Routers/Translators/AbstractTranslator.php
the texts of result codes app/Locale/results/ + app/Core/Utils/Results/ResultCode.php
the binding convention skills/cs-translations/SKILL.md

Follow-up chapters: Routing · Forms · Cache · Code conventions