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 |
- 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#
- 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.
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.php → setupTemplateTranslator() |
| the domain in a component | app/Core/Base/BaseComponent.php → setup() |
| 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