HardТеория8 min

Система переводов (Translations)

TranslatorInterface, ICU MessageFormat, файлы переводов (YAML/XLIFF), pluralization, определение локали, фильтр trans в Twig, извлечение переводов, домены

Symfony Translation component -- мощная система интернационализации (i18n). На экзамене проверяют: форматы файлов переводов, ICU MessageFormat, pluralization, домены, определение локали и интеграцию с Twig.

Установка и конфигурация

composer require symfony/translation
# config/packages/translation.yaml
framework:
    default_locale: 'ru'
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks:
            - 'en'
        providers:
            # Optional: external translation providers
            # crowdin:
            #     dsn: '%env(CROWDIN_DSN)%'

Подвох экзамена: fallbacks -- массив локалей. Если перевод не найден для текущей локали, Symfony ищет по цепочке fallbacks. Если не найден нигде -- возвращает оригинальный ключ.

TranslatorInterface

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;

final class OrderController extends AbstractController
{
    public function __construct(
        private readonly TranslatorInterface $translator,
    ) {}

    #[Route('/orders/{id}', name: 'order_show')]
    public function show(int $id): Response
    {
        // Basic translation
        $message = $this->translator->trans('order.created');

        // Translation with parameters
        $message = $this->translator->trans('order.total', [
            '{total}' => '1 500',
            '{currency}' => 'RUB',
        ]);

        // Explicit locale override
        $message = $this->translator->trans(
            'order.created',
            [],         // parameters
            'messages', // domain
            'en',       // locale override
        );

        return new Response($message);
    }
}

Сигнатура метода trans()

<?php

declare(strict_types=1);

// TranslatorInterface::trans() signature
public function trans(
    string $id,                    // Translation key
    array $parameters = [],        // Replacement parameters
    ?string $domain = null,        // Translation domain (default: 'messages')
    ?string $locale = null,        // Locale override (default: current locale)
): string;

Подвох экзамена: Порядок аргументов trans(): id, parameters, domain, locale. Если нужно указать locale, но не domain -- передайте null или 'messages' в качестве domain.

Форматы файлов переводов

Symfony поддерживает несколько форматов. Имена файлов следуют конвенции: domain.locale.format.

translations/
    messages.ru.yaml        # Домен: messages, Локаль: ru, Формат: YAML
    messages.en.yaml        # Домен: messages, Локаль: en
    messages.ru.xlf         # XLIFF формат
    validators.ru.yaml      # Домен: validators
    security.ru.yaml        # Домен: security
    admin.ru.yaml           # Кастомный домен: admin

YAML формат

# translations/messages.ru.yaml
order:
    created: 'Заказ успешно создан'
    total: 'Итого: {total} {currency}'
    status:
        pending: 'В ожидании'
        confirmed: 'Подтверждён'
        shipped: 'Отправлен'
        delivered: 'Доставлен'
        cancelled: 'Отменён'

user:
    greeting: 'Здравствуйте, {name}!'
    role:
        admin: 'Администратор'
        manager: 'Менеджер'
        user: 'Пользователь'

# Nested keys are flattened to dot notation
# 'order.created' => 'Заказ успешно создан'
# 'order.status.pending' => 'В ожидании'
# translations/messages.en.yaml
order:
    created: 'Order successfully created'
    total: 'Total: {total} {currency}'
    status:
        pending: 'Pending'
        confirmed: 'Confirmed'
        shipped: 'Shipped'
        delivered: 'Delivered'
        cancelled: 'Cancelled'

user:
    greeting: 'Hello, {name}!'

XLIFF формат (рекомендуемый для production)

<!-- translations/messages.ru.xlf -->
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
    <file source-language="en" target-language="ru" datatype="plaintext"
          original="messages">
        <body>
            <trans-unit id="order.created">
                <source>order.created</source>
                <target>Заказ успешно создан</target>
            </trans-unit>
            <trans-unit id="order.total">
                <source>order.total</source>
                <target>Итого: {total} {currency}</target>
            </trans-unit>
            <trans-unit id="user.greeting">
                <source>user.greeting</source>
                <target>Здравствуйте, {name}!</target>
                <note>Greeting message for the user</note>
            </trans-unit>
        </body>
    </file>
</xliff>

Подвох экзамена: XLIFF -- рекомендуемый формат для профессиональных переводов. Он поддерживается профессиональными инструментами перевода (CAT tools). YAML проще для разработки. Оба формата поддерживают одинаковые возможности в Symfony.

XLIFF 2.0

<!-- translations/messages+intl-icu.ru.xlf -->
<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0"
       version="2.0" srcLang="en" trgLang="ru">
    <file id="messages">
        <unit id="order.created">
            <segment>
                <source>order.created</source>
                <target>Заказ успешно создан</target>
            </segment>
        </unit>
    </file>
</xliff>

ICU MessageFormat

Для включения ICU MessageFormat добавьте +intl-icu к имени файла.

# translations/messages+intl-icu.ru.yaml
# ICU MessageFormat enabled for this file

# Simple parameter substitution (uses {param} not %param%)
greeting: 'Здравствуйте, {name}!'

# Pluralization with ICU plural rules
items_count: >-
    {count, plural,
        one   {# товар в корзине}
        few   {# товара в корзине}
        many  {# товаров в корзине}
        other {# товаров в корзине}
    }

# Select based on gender
user_action: >-
    {gender, select,
        female {{name} добавила товар в корзину}
        male   {{name} добавил товар в корзину}
        other  {{name} добавил(а) товар в корзину}
    }

# Number formatting
price: 'Цена: {price, number, currency}'

# Date formatting
created_at: 'Создан: {date, date, medium}'

# Nested: plural + select
notification: >-
    {gender, select,
        female {{count, plural,
            one   {{name} оставила # отзыв}
            few   {{name} оставила # отзыва}
            many  {{name} оставила # отзывов}
            other {{name} оставила # отзывов}
        }}
        other {{count, plural,
            one   {{name} оставил # отзыв}
            few   {{name} оставил # отзыва}
            many  {{name} оставил # отзывов}
            other {{name} оставил # отзывов}
        }}
    }
<?php

declare(strict_types=1);

// Using ICU MessageFormat translations
$translator->trans('items_count', ['count' => 1]);
// "1 товар в корзине"

$translator->trans('items_count', ['count' => 3]);
// "3 товара в корзине"

$translator->trans('items_count', ['count' => 15]);
// "15 товаров в корзине"

$translator->trans('user_action', [
    'gender' => 'female',
    'name' => 'Мария',
]);
// "Мария добавила товар в корзину"

Подвох экзамена: ICU MessageFormat активируется через +intl-icu в имени файла (например, messages+intl-icu.ru.yaml). Без этого суффикса Symfony использует стандартный формат с %param%. В ICU используются {param} и # для текущего числа.

Разница стандартного и ICU формата

Аспект Стандартный ICU MessageFormat
Файл messages.ru.yaml messages+intl-icu.ru.yaml
Параметры %name% или {name} {name}
Pluralization {0} No items|{1} One item|]1,Inf[ %count% items {count, plural, one {# item} other {# items}}
Gender Не поддерживается {gender, select, ...}
Number format Ручное {price, number, currency}

Pluralization

Стандартный формат (без ICU)

# translations/messages.ru.yaml (standard format)
items_in_cart: '{0} Корзина пуста|{1} В корзине один товар|]1,Inf[ В корзине %count% товаров'
<?php

declare(strict_types=1);

// Standard pluralization
$translator->trans('items_in_cart', ['%count%' => 0]);
// "Корзина пуста"

$translator->trans('items_in_cart', ['%count%' => 1]);
// "В корзине один товар"

$translator->trans('items_in_cart', ['%count%' => 42]);
// "В корзине 42 товаров"

ICU plural rules для русского языка

one:   1, 21, 31, 41, ...     (кроме 11)
few:   2-4, 22-24, 32-34, ... (кроме 12-14)
many:  0, 5-20, 25-30, ...
other: 1.5, 2.3, ...          (дробные числа)
# translations/messages+intl-icu.ru.yaml
files_uploaded: >-
    {count, plural,
        one   {Загружен {count} файл}
        few   {Загружено {count} файла}
        many  {Загружено {count} файлов}
        other {Загружено {count} файлов}
    }

Подвох экзамена: В русском языке ЧЕТЫРЕ формы множественного числа в ICU: one, few, many, other. В английском только две: one и other. Если забыть few или many для русского -- перевод может работать некорректно.

Домены переводов (Translation Domains)

Домены разделяют переводы по контексту. По умолчанию используется домен messages.

# translations/messages.ru.yaml — default domain
app:
    title: 'Мой магазин'

# translations/validators.ru.yaml — validation messages
user.email.unique: 'Этот email уже занят'
product.name.blank: 'Название товара обязательно'

# translations/security.ru.yaml — security messages
login.bad_credentials: 'Неверный логин или пароль'
login.account_disabled: 'Аккаунт деактивирован'

# translations/admin.ru.yaml — custom domain for admin panel
dashboard.title: 'Панель управления'
users.list: 'Список пользователей'
<?php

declare(strict_types=1);

// Using domains
$translator->trans('app.title');                        // domain: messages (default)
$translator->trans('app.title', [], 'messages');        // explicit default domain
$translator->trans('user.email.unique', [], 'validators');
$translator->trans('login.bad_credentials', [], 'security');
$translator->trans('dashboard.title', [], 'admin');

Подвох экзамена: Домен validators используется автоматически компонентом Validator для сообщений об ошибках валидации. Домен security -- для сообщений безопасности. Кастомные домены полезны для изоляции переводов (например, admin, email, api).

Переводы в Twig

Фильтр trans

{# Basic translation #}
{{ 'order.created'|trans }}

{# With parameters #}
{{ 'order.total'|trans({'{total}': order.total, '{currency}': 'RUB'}) }}

{# With explicit domain #}
{{ 'dashboard.title'|trans({}, 'admin') }}

{# With explicit locale #}
{{ 'order.created'|trans({}, 'messages', 'en') }}

{# ICU MessageFormat parameters #}
{{ 'items_count'|trans({count: cart.itemsCount}) }}
{{ 'user_action'|trans({gender: user.gender, name: user.name}) }}

Тег trans

{# Block translation (for long texts) #}
{% trans %}order.created{% endtrans %}

{# With domain #}
{% trans from 'admin' %}dashboard.title{% endtrans %}

{# With locale #}
{% trans into 'en' %}order.created{% endtrans %}

{# With parameters #}
{% trans with {'{name}': user.name} %}user.greeting{% endtrans %}

Перевод в шаблонах форм

{# Form labels are auto-translated from 'messages' domain #}
{{ form_label(form.name) }}

{# Override label translation domain #}
{{ form_label(form.name, null, {
    'translation_domain': 'admin'
}) }}

{# Disable translation for a field #}
{{ form_row(form.internalCode, {
    'translation_domain': false
}) }}

Pluralization в Twig (ICU)

{# ICU pluralization #}
{{ 'items_count'|trans({count: cart.count}) }}

{# Standard pluralization (deprecated approach) #}
{{ 'items_in_cart'|trans({'%count%': cart.count}) }}

Определение локали (Locale Detection)

Через маршруты

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\Routing\Attribute\Route;

// Locale in URL prefix
#[Route('/{_locale}/products', name: 'product_list', requirements: ['_locale' => 'ru|en|de'])]
public function list(): Response
{
    // _locale is automatically set as request locale
    return $this->render('product/list.html.twig');
}

// Global locale prefix for all routes in controller
#[Route('/{_locale}', requirements: ['_locale' => 'ru|en|de'])]
final class ProductController extends AbstractController
{
    #[Route('/products', name: 'product_list')]
    public function list(): Response
    {
        return $this->render('product/list.html.twig');
    }
}

Через Event Listener

<?php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;

#[AsEventListener(event: RequestEvent::class, priority: 20)]
final class LocaleListener
{
    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // Priority: URL param > session > Accept-Language header > default
        $locale = $request->query->get('lang')
            ?? $request->getSession()->get('_locale')
            ?? $request->getPreferredLanguage(['ru', 'en', 'de'])
            ?? 'ru';

        $request->setLocale($locale);
    }
}

Подвох экзамена: Специальный параметр _locale в маршруте автоматически устанавливает локаль на объекте Request. Это встроенное поведение Symfony. Для других способов определения локали нужен EventListener, выполняющийся ДО LocaleListener Symfony (priority > 16).

Извлечение переводов (Translation Extraction)

# Extract translation keys from templates and PHP code
php bin/console translation:extract ru --force --format=yaml

# Extract to XLIFF format
php bin/console translation:extract ru --force --format=xlf

# Extract for specific domain
php bin/console translation:extract ru --force --domain=admin

# Preview without writing (dry-run)
php bin/console translation:extract ru

# Show statistics
php bin/console debug:translation ru
php bin/console debug:translation ru --domain=messages
<?php

declare(strict_types=1);

// For the extractor to find translations in PHP code,
// use the TranslatableMessage or t() shortcut

use Symfony\Component\Translation\TranslatableMessage;

// Option 1: TranslatableMessage object
$message = new TranslatableMessage('order.created', [], 'messages');

// Option 2: t() shortcut (in controllers extending AbstractController)
$message = $this->translator->trans('order.created');

// Option 3: TranslatableMessage with parameters
$message = new TranslatableMessage('user.greeting', [
    '{name}' => $user->getName(),
]);

TranslatableMessage

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\Translation\TranslatableMessage;

final class OrderService
{
    // TranslatableMessage defers translation until rendering
    public function getStatusLabel(string $status): TranslatableMessage
    {
        return new TranslatableMessage(
            'order.status.' . $status,
            [],
            'messages',
        );
    }
}
{# TranslatableMessage is auto-translated in Twig #}
{{ order_status_label }}

{# Explicitly translate #}
{{ order_status_label|trans }}

Переводы в Validator

<?php

declare(strict_types=1);

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;

final class Product
{
    // Validator automatically uses 'validators' domain
    #[Assert\NotBlank(message: 'product.name.required')]
    #[Assert\Length(
        min: 3,
        max: 255,
        minMessage: 'product.name.too_short',
        maxMessage: 'product.name.too_long',
    )]
    private string $name = '';

    #[Assert\Positive(message: 'product.price.positive')]
    private int $price = 0;
}
# translations/validators.ru.yaml
product:
    name:
        required: 'Название товара обязательно'
        too_short: 'Название должно быть не менее {{ limit }} символов'
        too_long: 'Название не должно превышать {{ limit }} символов'
    price:
        positive: 'Цена должна быть положительной'

Подвох экзамена: Параметры валидатора в переводах используют {{ limit }} (двойные фигурные скобки), а НЕ {limit} или %limit%. Это специфика Validator component. Не путайте с ICU {param} или стандартным %param%.

Переводы в формах

<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, [
                'label' => 'product.form.name', // Auto-translated
                'help' => 'product.form.name_help',
            ])
            ->add('status', ChoiceType::class, [
                'choices' => [
                    'order.status.pending' => 'pending',   // Keys are translated
                    'order.status.confirmed' => 'confirmed',
                    'order.status.shipped' => 'shipped',
                ],
                'choice_translation_domain' => 'messages',
            ]);
    }

    public function configureOptions(OptionsResolver $options): void
    {
        $options->setDefaults([
            'translation_domain' => 'messages',
        ]);
    }
}

Кэширование переводов

# config/packages/translation.yaml
framework:
    translator:
        cache_dir: '%kernel.cache_dir%/translations'

В production переводы кэшируются автоматически. При разработке (APP_ENV=dev) кэш обновляется при каждом изменении файлов.

# Clear translation cache
php bin/console cache:clear

# Warm up cache (including translations)
php bin/console cache:warmup

Итоги

  • TranslatorInterface::trans() -- основной метод перевода: id, parameters, domain, locale
  • Форматы файлов: YAML (простой), XLIFF (профессиональный), PHP
  • ICU MessageFormat: messages+intl-icu.locale.yaml -- plural, select, number, date
  • Русский язык: 4 формы plural (one, few, many, other)
  • Домены: messages (по умолчанию), validators, security, кастомные
  • _locale в маршруте автоматически устанавливает локаль
  • TranslatableMessage -- отложенный перевод до момента рендеринга
  • Параметры: стандартный %param%, ICU {param}, Validator {{ param }}

Проверь себя

Что произойдёт при вызове `$translator->trans('key.not.exists')`?

Сколько форм множественного числа нужно указать для русского языка в ICU MessageFormat?

Какой формат параметров используется в сообщениях валидатора Symfony?

Какой домен по умолчанию использует компонент Validator для сообщений об ошибках?

Как активировать ICU MessageFormat для файла переводов в Symfony?