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 large codebase with dozens of entities — users, departments, products, items, orders, records, categories — 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:
order.fields.due_date 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' => $user->getName() ] );
_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
order.fields.due_date: Due Date
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}
An Order entity's $dueDate property → order.fields.due_date. 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.
The auto-write behaviour is opt-out for callers that exercise _t() with keys that should never land in committed translation files:
PHP_SF\System\Core\TranslatorV2::setAutoWriteMissingKeys( bool $enabled ): void
true — missing keys are written to all locale files with a {key}_not_translated stub._t() (e.g. app_setting.*, fixture-generated keys). With auto-write on, every run would dirty translations/*.yaml with _not_translated placeholders.tests/bootstrap.php calls setAutoWriteMissingKeys(false) for exactly that reason — PHPUnit suites pass DB-derived keys through _t() on every run, and CI must never mutate the committed translation files. This is mostly relevant in the test environment; production should keep auto-write on so missing keys surface during normal development.
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 display names are defined as constants on PHP_SF\System\Classes\Helpers\Locale (full CLDR coverage). The constants hold the human-readable English name, not the locale key:
Locale::en // 'English' (display name)
Locale::en_US // 'English (United States)'
Locale::pl // 'Polish'
Locale::uk // 'Ukrainian'
To get the locale key from a display name, call getLocaleKey():
Locale::getLocaleKey( 'English' ) // 'en'
Locale::getLocaleName( 'en_US' ) // 'English (United States)'
Locale::checkLocaleKey( 'pl' ) // true
In LANGUAGES_LIST and lang/<locale>.yaml filenames you write the key string directly ('en', 'pl', 'uk') — the Locale::en constant is for code that needs the human-readable label, not the key.
In production, each locale catalog is cached in the active cache pool after the first load:
translator_v2:en
translator_v2:uk
translator_v2:pl
TTL is 24 hours (AbstractCacheAdapter::DEFAULT_TTL = 86400). The cache adapter's default applies; the active adapter may override the default to null for keys that should never expire. Clear the cache with app:cache:clear to force a reload after updating locale files.
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 ca() (APCu → Redis → Memcached → FileSystem, depending on which adapter is active). 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.