The framework includes a self-maintaining translation system. Locale files are auto-generated when missing, new translation keys are added automatically when first used, and files are kept sorted and formatted on every write. The only requirement is that the translation directory exists.
_t( string $stringName, ...$values ): string
Translates a string to the current locale. Supports sprintf-style placeholders:
// Simple translation
echo _t( 'Welcome to the game' );
// With placeholders
echo _t( 'Hello, %s!', $player->getLogin() );
echo _t( 'Field `%s` must be between %s and %s', 'level', 1, 100 );
If the key doesn't exist in the current locale file, it's added automatically with a (not_translated) suffix and the key itself is returned as the translation. This means missing translations are always visible in the UI rather than causing errors.
_tt( string $stringName, string $translateTo, ...$values ): string
Translates to a specific locale regardless of the current session locale:
// Always translate to Polish
echo _tt( 'Confirm', 'pl' );
// Useful for emails sent in the recipient's language
$subject = _tt( 'You have a new message', $recipient->getLocale() );
Throws InvalidConfigurationException if $translateTo is not in LANGUAGES_LIST.
Both _t() and _tt() wrap their output in nl2br() — newlines in translation strings are automatically converted to <br> tags.
Locale files live in the directory registered via addTranslationFiles() during kernel bootstrap. Each file is named after its locale key and returns a plain PHP associative array:
// lang/en.php
<?php /** @noinspection ALL @formatter::off */ return [
//
//
'Accept' => 'Accept',
'Access Denied' => 'Access Denied',
//
//
'Banned' => 'Banned',
//
//
'Confirm' => 'Confirm',
'Created At' => 'Created At',
//
//
'E-mail' => 'E-mail',
'email_and_password_cannot_be_empty' => 'Email and password cannot be empty.',
//
];
The // comment lines are section dividers inserted automatically by the Translator between alphabetical groups. The @formatter::off annotation tells PhpStorm not to reformat the file.
The Translator manages locale files automatically in DEV_MODE. You can delete files, rename keys, add new languages — the system will rebuild what it needs. Specifically:
Missing locale file — if a locale in LANGUAGES_LIST has no corresponding file in the translation directory, it's created automatically on the next request with an explanatory comment and an empty array.
Missing translation key — when _t( 'some_key' ) is called and some_key doesn't exist in the current locale, the key is added to all locale files with the value 'some_key (not_translated)'. The string is returned as-is for the current request, and future requests see the untranslated placeholder until someone fills it in.
File sorting — every time the Translator writes to a locale file, it sorts all keys alphabetically and inserts // section dividers between groups. This keeps files human-readable and diff-friendly regardless of what order keys were added.
Entity properties — in DEV_MODE, the Translator scans all entity classes in ENTITY_DIRECTORY for TranslatablePropertyName attributes and adds any missing property names to locale files automatically. This means adding a new entity property with #[TranslatablePropertyName('My Property')] is enough — the translation key appears in all locale files on the next request without any manual step.
In production (DEV_MODE = false), none of this happens. Translation strings are loaded once from Redis cache and the files are never touched. Make sure all locale files are complete before deploying.
Three steps:
1. Add the locale key to LANGUAGES_LIST in config/constants.php:
const LANGUAGES_LIST = [ 'en', 'pl', 'uk' ];
2. Make sure the translation directory exists — the file itself does not need to exist. The Translator will create it automatically on the next request in DEV_MODE.
3. Translate the strings — the generated file will contain all existing keys with (not_translated) suffixes. Replace them with actual translations.
That's it. The new locale is immediately available in Lang::setCurrentLocale() and _tt().
The current locale is stored in the session and defaults to the first entry in LANGUAGES_LIST if no session locale is set and the browser's Accept-Language header doesn't match any supported locale.
// Get current locale
Lang::getCurrentLocale(); // 'en'
// Set locale (must be in LANGUAGES_LIST)
Lang::setCurrentLocale( 'pl' );
Lang::setCurrentLocale() silently ignores locale keys not in LANGUAGES_LIST — no exception is thrown, the locale simply doesn't change. The framework provides two built-in routes for changing the locale:
// POST — used by the language selector form
#[Route( url: 'settings/change_language', httpMethod: 'POST' )]
// GET — used by direct links like /settings/change_language/pl
#[Route( url: 'settings/change_language/{lang}', httpMethod: 'GET' )]
And an API endpoint:
// POST api/lang/change_language with body: lang=pl
#[Route( url: 'api/lang/change_language', httpMethod: 'POST' )]
Every protected property on an entity that participates in validation must have a #[TranslatablePropertyName] attribute. This provides the human-readable property name used in validation error messages:
use PHP_SF\System\Attributes\Validator\TranslatablePropertyName;
use PHP_SF\System\Attributes\Validator\Constraints as Validate;
#[Validate\Length( min: 6, max: 50 )]
#[TranslatablePropertyName( 'E-mail' )]
#[ORM\Column( type: 'string', unique: true )]
protected string $email;
When validation fails, the error message becomes:
Field `E-mail` is too short. It should have 6 character or more.
The TranslatablePropertyName value is a translation key — it goes through _t() before being inserted into error messages. This means the property name itself is translated to the current locale.
In DEV_MODE, the Translator scans all entity classes and adds any TranslatablePropertyName values to locale files automatically. Missing #[TranslatablePropertyName] attributes on entity properties throw InvalidEntityConfigurationException during validation — this is a hard requirement, not optional.
Multiple translation directories can be registered during kernel bootstrap — useful for separating framework-level strings from application strings:
$kernel = ( new PHP_SF\Kernel )
->addTranslationFiles( __DIR__ . '/../lang' );
When multiple directories are registered, locale files from all directories are loaded and merged for each locale. Keys from later directories override keys from earlier ones if there's a collision. Each directory is written to independently when new keys are added — the Translator only writes to directories it can locate on the filesystem and that are not inside vendor/.
In production (DEV_MODE = false), translations are cached in Redis after the first load:
translated_strings:{locale}
For example translated_strings:en, translated_strings:pl. These keys have no TTL and persist until app:cache:clear is run.
In DEV_MODE, translations are always loaded from files and Redis is never written to for translation data — changes to locale files are picked up on every request.
After updating locale files in production, run app:cache:clear to flush the translation cache. The next request will reload from files and re-cache.
When a user visits for the first time with no session locale set, the framework checks $_SERVER['HTTP_ACCEPT_LANGUAGE'] and attempts to match it against the constants in PHP_SF\System\Classes\Helpers\Locale:
// In PHP_SF\System\Kernel::setDefaultLocale()
$acceptLanguages = explode( ',', $_SERVER['HTTP_ACCEPT_LANGUAGE'] );
foreach ( $acceptLanguages as $langCode )
if ( $rc->hasConstant( $langCode ) && in_array( $langCode, LANGUAGES_LIST, true ) ) {
define( 'DEFAULT_LOCALE', Locale::getLocaleKey( $rc->getConstant( $langCode ) ) );
s()->set( 'locale', DEFAULT_LOCALE );
return;
}
// Fall back to first language in LANGUAGES_LIST
define( 'DEFAULT_LOCALE', LANGUAGES_LIST[0] );
If the browser language matches a supported locale, it's set automatically. Otherwise the first language in LANGUAGES_LIST is used.
All available locale keys are defined as constants on PHP_SF\System\Classes\Helpers\Locale. The class covers all CLDR locales:
Locale::en // 'English'
Locale::en_US // 'English (United States)'
Locale::pl // 'Polish'
Locale::pl_PL // 'Polish (Poland)'
Locale::uk // 'Ukrainian'
Locale::uk_UA // 'Ukrainian (Ukraine)'
// ... hundreds more
Helper methods:
// Get locale name from key
Locale::getLocaleName( 'en_US' ) // 'English (United States)'
// Get locale key from name
Locale::getLocaleKey( 'English' ) // 'en'
// Check if a key exists
Locale::checkLocaleKey( 'pl' ) // true
// Check if a name exists
Locale::checkLocaleName( 'Polish' ) // true
Forgetting #[TranslatablePropertyName] on entity properties — this throws InvalidEntityConfigurationException during validation with a clear message pointing to the missing attribute. Every non-boolean protected property on an entity that uses validation constraints needs this attribute.
Expecting untranslated keys to throw — they don't. A missing key is added to all locale files with (not_translated) suffix and the key itself is returned. This is intentional — silent failures are worse than visible (not_translated) placeholders in the UI.
Editing locale files in production — locale files are loaded once per process in production and cached in Redis. Editing a file directly on the production server has no effect until the Redis translation cache is cleared with app:cache:clear.
Adding a language without translating — adding a locale key to LANGUAGES_LIST immediately makes it selectable by users. If the locale file is full of (not_translated) values, users who switch to that language will see a broken UI. Either translate before enabling or feature-flag the language selector until translations are complete.
Using _t() for user-generated content — _t() is for static application strings defined by developers. Passing dynamic user content through _t() will add every unique user string as a translation key to your locale files in DEV_MODE, polluting them with thousands of entries. Only pass string literals or well-defined constant-like keys to _t().