The framework's translation system is TranslatorV2 — it implements Symfony\Contracts\Translation\TranslatorInterface but works before the Symfony container boots, so it's available from the very first line of a request. Under the hood it's the same contract, the same locale fallback concept, the same idea — but with a few additions that make it usable in a large codebase without turning translation management into its own full-time job.
You could. But a game codebase with dozens of entities — players, alliances, buildings, items, procedures, inventory slots, skill trees — means a lot of validation error messages. Each message needs to reference the field name in a human-readable way: "Field Level: must be between 1 and 100" not "Field level: must be between 1 and 100". That field label needs to be translated too. And that label key needs to exist in every locale file.
In practice, what happens is:
building.fields.construction_time in the UIen.yaml but forgets to add a stub to pl.yaml and fr.yamlTranslatorV2 solves this by doing the bookkeeping automatically:
post.fields.created_at manually — it's derived from the class and property names.en.yaml, and on the next request pl.yaml and fr.yaml get stub entries automatically._t('something.that.doesnt.exist') in development and the key appears in all locale files immediately. You see something.that.doesnt.exist_not_translated in the browser, which is ugly enough that you'll notice it and fix it, but not catastrophic._t( string $id, array $parameters = [] ): string
Translates a key to the current locale:
echo _t( 'common.save' );
echo _t( 'user.welcome', [ 'name' => $player->getLogin() ] );
_tt( string $id, string $translateTo, array $parameters = [] ): string
Translates to a specific locale regardless of the current session locale — useful for emails or notifications sent in the recipient's language:
$subject = _tt( 'mail.new_message_subject', $recipient->getLocale() );
Both functions wrap output in nl2br(). If you need the raw string without HTML (for CLI output, API responses, email plain-text), call TranslatorV2::getInstance()->trans() directly.
Locale files are YAML, named {locale}.yaml, and live in the directory registered during kernel bootstrap:
# translations/en.yaml
common.save: Save
common.cancel: Cancel
common.greeting: "Hello, {name}!"
validation:
required: This field is required.
too_long: "{field} must be at most {max} characters."
Both flat dot-notation keys and nested YAML maps are supported and equivalent — validation.required and a nested validation: { required: ... } resolve to the same key. Use whichever style fits the file better.
Use {name} placeholders in translation values:
user.welcome: "Welcome back, {name}!"
order.summary: "Order #{id}: {count} items totalling {total}."
echo _t( 'user.welcome', [ 'name' => 'Alice' ] );
// → "Welcome back, Alice!"
echo _t( 'order.summary', [ 'id' => 1042, 'count' => 3, 'total' => '$24.99' ] );
// → "Order #1042: 3 items totalling $24.99."
Extra parameters not in the value string are silently ignored. Missing parameters leave {name} untouched in the output.
A parameter value prefixed with @: is resolved as a translation key before interpolation:
post.fields.title: Title
validation.too_long: "{field} is too long."
echo _t( 'validation.too_long', [ 'field' => '@:post.fields.title' ] );
// → "Title is too long."
This is what AbstractEntity::validate() uses to compose validation error messages. The field label is a translation key reference, so it gets translated to the current locale along with the rest of the message. References resolve with the same locale fallback as normal keys — if the key doesn't exist in the current locale, the default locale is tried; if it still doesn't exist, the raw key name is returned.
When AbstractEntity::validate() encounters a validation failure, it composes the error using the framework's built-in key:
# Platform/lang/en.yaml
entity.field_validation_error: "Field `{field}`: {message}"
The {field} parameter is an @:key reference to the auto-generated entity field key. For a Post entity with a $title property, that key is:
post.fields.title
The {message} is the fully-rendered Symfony validator constraint message, translated using Symfony's bundled .xlf files for the current session locale (Lang::getCurrentLocale()). Symfony validator translations are cached per locale, so switching locale mid-session produces correctly localized constraint messages.
You don't write these keys — they're derived from the entity class and property names. You do translate them, though:
# translations/en.yaml
post.fields.title: Title
post.fields.created_at: Created At
building.fields.construction_time: Construction Time
If you leave a field key untranslated, the validation error will show the raw key name as the field label, which is readable enough to understand but not user-facing quality. Replace stubs with real labels before shipping.
This is the part that makes the system self-maintaining.
Missing key — when a key isn't found in any locale, it's added to all locale files with the value {key}_not_translated and that value is returned for the current request. No exception, no empty string, no silent failure — the _not_translated suffix is ugly enough to catch in review.
Entity field key scanning — on every request, TranslatorV2 scans App/Entity/ and generates a translation key for every property carrying #[ORM\Column], #[ORM\JoinColumn], or #[ORM\JoinColumns]:
{entity_short_name_snake_case}.fields.{property_snake_case}
A Building entity's $constructionTime property → building.fields.construction_time. New entity, new properties, next request — the keys appear in all locale files with _not_translated stubs. No attribute required, no manual step.
Locale file sync — after loading, each registered directory is checked: any key present in one locale file but missing from another is added to the missing file with a _not_translated value. Add common.new_feature to en.yaml, and on the next request fr.yaml and pl.yaml get stub entries automatically.
In production (DEV_MODE = false), none of this happens. Translations are loaded from Redis cache. Files are never written or scanned.
LANGUAGES_LIST in config/constants.php:const LANGUAGES_LIST = [ 'en', 'fr', 'pl' ];
DEV_MODE, a pl.yaml is created in each registered directory with all existing keys set to _not_translated. Replace stubs with actual translations.The new locale is immediately available in Lang::setCurrentLocale() and _tt().
The current locale is stored in the session. On first visit, Accept-Language is checked against LANGUAGES_LIST — if it matches, that locale is set automatically. Otherwise the first entry in LANGUAGES_LIST is used.
Lang::getCurrentLocale(); // 'en'
Lang::setCurrentLocale( 'pl' ); // silently ignores locales not in LANGUAGES_LIST
The framework provides built-in routes for language switching:
// Form POST
#[Route( url: 'settings/change_language', httpMethod: 'POST' )]
// Direct link: /settings/change_language/pl
#[Route( url: 'settings/change_language/{lang}', httpMethod: 'GET' )]
// API
#[Route( url: 'api/lang/change_language', httpMethod: 'POST' )]
All locale keys are defined as constants on PHP_SF\System\Classes\Helpers\Locale (full CLDR coverage):
Locale::en // 'en'
Locale::en_US // 'en_US'
Locale::pl // 'pl'
Locale::uk // 'uk'
Locale::getLocaleName( 'en_US' ) // 'English (United States)'
Locale::getLocaleKey( 'Polish' ) // 'pl'
Locale::checkLocaleKey( 'pl' ) // true
In production, each locale catalog is cached in Redis after the first load:
translator_v2:en
translator_v2:uk
translator_v2:pl
No TTL — keys persist until app:cache:clear runs. After updating locale files in production, clear the cache.
Using %s placeholders — TranslatorV2 uses named {param} placeholders. %s passes through verbatim.
Expecting missing keys to throw — they don't. A missing key is written to all locale files and the suffixed value is returned. This is intentional — visible, not fatal.
Editing locale files directly on production — translations are cached in Redis. File edits have no effect until app:cache:clear is run.
Adding a language before translating — adding a locale to LANGUAGES_LIST makes it immediately selectable. If the locale file is full of _not_translated stubs, users who switch to that language will see raw key names everywhere. Don't ship a language until it's actually translated.
Using _t() for dynamic user content — _t() is for static application strings. Passing user-generated content through _t() will add every unique string as a translation key to locale files in DEV_MODE.