Skip to content

Multilingual support

Multilingual support is a core feature in CPalius, not a plugin. There are three separate topics and they must not be confused:

  1. Which languages do you serve the site in? (the language list)
  2. Content translations: how are the English and Turkish versions of a page linked?
  3. Interface translations: button, menu and error texts.

1. Languages

Active languages are read from the database and managed from the AACP → System → Languages screen (permission: system.localization.manage). When adding a language you do not have to type the code, English name and native name by hand; the add dialog has a language catalogue (you pick "Deutsch"). The catalogue is not an allowlist: the form accepts any code of the form [a-z]{2,5}.

A language carries: code (en), name (Turkish), nativeName (Türkçe), whether it is the default, and an order number.

LocaleProvider is the single source for language information:

$provider->getCodes();          // ['tr', 'en']
$provider->getDefaultCode();    // 'tr'
$provider->isSupported('de');   // false
$provider->resolve('de');       // if unsupported, falls back to the default: 'tr'
Fallback language list

CPALIUS_LOCALES and CPALIUS_DEFAULT_LOCALE in .env are used only when the database cannot be reached or the language table is empty. The %cpalius.locales_pattern% parameter in route constraints is also generated from this list if the database cannot be reached at build time. Run cache:clear after changing the list.

2. Content translations

A translation of a piece of content is a separate row. The rows of the same content in different languages share a common translation_group_id (UUID). This is not a foreign key; it is a "group label".

Node #12  tr  slug=merhaba-dunya    group=7c1e…
Node #19  en  slug=hello-world      group=7c1e…

Database constraints ensure integrity:

  • UNIQUE(slug, locale): the same address does not appear twice in the same language; it may in different languages.
  • UNIQUE(translation_group_id, locale): there is at most one translation per language in a group.

A global slug or group constraint would break a multilingual database; that is why the constraints are composite.

Translation in Studio

When editing content, translation tabs appear: they show existing siblings and missing languages (TranslationGroupResolver). When you create a translation in a missing language, the new row is linked to the same group. If the query fails the tabs stay empty and the page does not break.

Language switcher

On the front end, the equivalent of the current page in another language is found with LocaleSwitchService:

  • If a translation exists, it redirects to its slug counterpart.
  • If no translation exists, the same route is kept and the target page can show a soft "not available in this language" state; there is no hard 404 jump to the home page.
  • <link rel="alternate" hreflang> links for the page are also generated with this service (alternateLinks).

Address structure

Front-end addresses start with a language prefix: /tr/blog/..., /en/docs/.... The admin panel's addresses (/aacp, /admin) have no language prefix; the panel language is chosen with the cp_locale cookie and the user's preference.

The user's language is written into User::$data['locale'] at registration; emails and notifications are produced in the recipient's language instead of the request's language (UserLocaleResolver).

3. Interface translations

Interface texts are Symfony translation files (YAML):

cp-content/translations/messages+intl-icu.tr.yaml     global translations
cp-content/translations/messages+intl-icu.en.yaml
cp-content/modules/<Name>/Resources/translations/...    module-specific

The default translation domain is messages+intl-icu; this is the ICU message format and supports plurals and number formatting:

# messages+intl-icu.en.yaml
docs.banner.latest: 'This is the documentation of an older version. The current version: {version}'
{{ 'docs.banner.latest'|trans({version: latest.label}) }}

Translation manager (AACP)

The AACP → Localization screen (TranslationManager) lists and edits the translation files of the core and all modules in one place:

  • Searching keys, editing values (writing is atomic: first .tmp, then rename)
  • Completion ratio: keys missing in the target languages compared with the source language
  • Missing key list (paged)
  • Export/import as JSON or YAML (key × language matrix)
  • Seeding the files for a language when opening a new one (seedLocale)

If translation is to be done with AI, the core defines only the contract (UiStringTranslatorInterface) and if an implementation exists, "translate with AI" actions appear. The core does not call any module directly; if there is no implementation, the buttons do not appear at all.

Rules for module authors

  1. Do not leave fixed text. Use translation keys for labels, messages and errors.
  2. Write at least the tr and en files together: cp:doctor checks key parity between the two languages.
  3. For a translatable entity use TranslatableInterface and TranslatableTrait (Node carries the same pattern).
  4. If you need to render a template in another language (e.g. an email), use LocaleScope::run($locale, $callback): Twig's |trans filter reads the current language of the translator, and you need to switch temporarily to another one.
  5. For language-bound entities, define the uniqueness constraint together with the language.
final class Page implements TranslatableInterface
{
    use TranslatableTrait;
    // UNIQUE(slug, locale)  and  UNIQUE(translation_group_id, locale)
}
Last updated: 13.10.2026