Dzień 14: Tłumaczenia
Dziś zajmiemy się internacjonalizacją (i18n — przygotowanie aplikacji na wiele języków) i lokalizacją (l10n — dostarczenie tłumaczeń). Udostępnimy interfejs Jobeet po angielsku i polsku, dodamy przełącznik języka i nauczymy się tłumaczyć teksty ze zmiennymi oraz liczbą mnogą.
Zmiana vs Symfony 4.2: korzystamy z formatu YAML (jak reszta projektu) zamiast XLIFF, ze współczesnej komendy
translation:extract(nietranslation:update) oraz z formatu ICU dla liczby mnogiej (dawnytranschoicezostał usunięty). Przełącznik języka robimy bez jQuery i Webpack Encore — na natywnym Bootstrap 5.
Konfiguracja
Komponent Translation jest w pakiecie --webapp. Ustaw domyślny język i fallback w
config/packages/translation.yaml:
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks: ['en']
fallbacks mówi, czego użyć, gdy brakuje tłumaczenia w bieżącym języku (tu: angielski jako awaryjny).
Zdefiniuj listę obsługiwanych języków jako parametr w config/services.yaml:
parameters:
app.locales: ['en', 'pl']
I udostępnij ją Twigowi jako zmienną globalną (przyda się w przełączniku) w config/packages/twig.yaml:
twig:
globals:
locales: '%app.locales%'
Tłumaczenia w szablonach
Każdy tekst zależny od języka przepuszczamy przez filtr |trans. Zamiast tłumaczyć całe zdania po
angielsku jako „klucze", używamy kluczy semantycznych (np. nav.post_job) — czytelniejszych i
odpornych na zmiany treści.
W templates/base.html.twig:
<title>{% block title %}{{ 'app.title'|trans }}{% endblock %}</title>
...
<a class="navbar-brand fw-bold" href="{{ path('job_list') }}">Jobeet</a>
<a href="{{ path('job_create') }}" class="btn btn-outline-light">{{ 'nav.post_job'|trans }}</a>
Gdy Symfony renderuje |trans, szuka tłumaczenia klucza dla bieżącego języka; jeśli nie znajdzie —
używa fallbacku.
Pliki tłumaczeń (YAML)
Tłumaczenia trzymamy w katalogu translations/, w plikach messages.<locale>.yaml (domena messages
jest domyślna). Utwórz translations/messages.en.yaml:
app:
title: 'Jobeet — your best job board'
nav:
post_job: 'Post a Job'
affiliates: 'Become an affiliate'
admin: 'Admin panel'
I translations/messages.pl.yaml:
app:
title: 'Jobeet — najlepsza tablica ofert pracy'
nav:
post_job: 'Dodaj ofertę'
affiliates: 'Zostań partnerem'
admin: 'Panel admina'
Zmiana vs Symfony 4.2: oryginał używał XLIFF i komendy
translation:update. My stosujemy YAML (jak cały projekt) — czytelniejszy do ręcznej edycji. XLIFF pozostaje poprawną alternatywą (bywa wygodniejszy z zewnętrznymi narzędziami tłumaczeniowymi).
Wyodrębnianie kluczy: translation:extract
Zamiast wypisywać klucze ręcznie, Symfony przeskanuje szablony i uzupełni pliki brakującymi kluczami:
docker compose exec app php bin/console translation:extract --force --format=yaml en
docker compose exec app php bin/console translation:extract --force --format=yaml pl
--forcezapisuje nowe klucze do plików,--cleanusuwa klucze, których już nie ma w szablonach.
Zmiana vs Symfony 4.2: komenda nazywa się teraz
translation:extract(dawniejtranslation:update).
Tłumaczenia z argumentami
Niektóre zdania zawierają zmienne. Na stronie kategorii tytuł „Oferty w kategorii X" ma nazwę kategorii w
środku. Używamy placeholdera %name%:
<h1>{{ 'category.jobs_title'|trans({ '%name%': category.name }) }}</h1>
# messages.en.yaml
category:
jobs_title: 'Jobs in the %name% category'
# messages.pl.yaml
category:
jobs_title: 'Oferty w kategorii %name%'
Placeholder %name% zostanie podmieniony na wartość przekazaną do trans().
Uwaga na XSS: nie wstawiaj do tłumaczeń surowej treści od użytkownika (np. przez
|raw). Jeśli tłumaczenie zawiera HTML (link), przekazuj tylko bezpieczne wartości jako argumenty.
Liczba mnoga z ICU
Zdania zależne od liczby (np. „Wygasa za 1 dzień" / „za 5 dni") wymagają odmiany — w polskim
skomplikowanej (1 / 2–4 / 5+). Współczesne Symfony rozwiązuje to formatem ICU. Umieść komunikaty w
pliku z sufiksem +intl-icu — translations/messages+intl-icu.en.yaml:
job:
expires: '{days, plural, =0 {Expires today} one {Expires in # day} other {Expires in # days}}'
I translations/messages+intl-icu.pl.yaml (polskie kategorie: one, few, many):
job:
expires: '{days, plural, =0 {Wygasa dziś} one {Wygasa za # dzień} few {Wygasa za # dni} many {Wygasa za # dni} other {Wygasa za # dni}}'
W panelu sterowania (templates/job/_control_panel.html.twig z Dnia 8) użyj klucza — ICU sam dobierze
właściwą formę na podstawie liczby:
{{ 'job.expires'|trans({ days: job.expiresAt.diff(date()).days }) }}
Zmiana vs Symfony 4.2: dawny filtr/tag
transchoice(składnia]-Inf,0] Expired|{1}...) został usunięty. Zastąpił go format ICU ({n, plural, ...}) — czytelniejszy i poprawnie obsługujący reguły odmiany każdego języka. Plik z komunikatami ICU ma sufiks+intl-icu.
Przełącznik języka (przez sesję)
Zamiast osadzać język w URL i prefiksować trasy, zapamiętamy wybrany język w sesji — to podejście stosowane w tym projekcie. Potrzebujemy trzech elementów.
1. Kontroler przełączający src/Controller/LocaleController.php:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class LocaleController extends AbstractController
{
#[Route('/locale/{locale}', name: 'app_locale_switch', requirements: ['locale' => 'en|pl'])]
public function switch(string $locale, Request $request): Response
{
$request->getSession()->set('_locale', $locale);
// wróć na poprzednią stronę
return $this->redirect($request->headers->get('referer', $this->generateUrl('job_list')));
}
}
2. Subskryber ustawiający język z sesji przy każdym żądaniu —
src/EventSubscriber/LocaleSubscriber.php:
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class LocaleSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly string $defaultLocale = 'en') {}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!$request->hasPreviousSession()) {
return;
}
$request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
}
public static function getSubscribedEvents(): array
{
// priorytet 20 — przed domyślnym LocaleListener Symfony
return [KernelEvents::REQUEST => [['onKernelRequest', 20]]];
}
}
3. Rozwijane menu w templates/base.html.twig (natywny dropdown Bootstrap 5 — JS dołączyliśmy w
Dniu 4, żadnego jQuery):
<div class="dropdown">
<button class="btn btn-outline-light dropdown-toggle" type="button" data-bs-toggle="dropdown">
{{ app.request.locale|upper }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for locale in locales %}
<li>
<a class="dropdown-item {{ locale == app.request.locale ? 'active' }}"
href="{{ path('app_locale_switch', { locale: locale }) }}">{{ locale|upper }}</a>
</li>
{% endfor %}
</ul>
</div>
Kliknięcie języka zapisuje go w sesji i wraca na tę samą stronę — cała aplikacja renderuje się w wybranym języku.
Zmiana vs Symfony 4.2: oryginał osadzał język w prefiksie URL (
/ru/...) i budował dropdown na jQuery + Webpack Encore + Node.js. My trzymamy język w sesji (LocaleController+LocaleSubscriber) i używamy natywnego dropdownu Bootstrap 5 — bez jQuery, bez Encore, bez Node. (Symfony 6.2+ udostępnia też serwisLocaleSwitcherdo programistycznej zmiany języka.)
Podsumowanie
Interfejs Jobeet jest w pełni wielojęzyczny:
- ✅ konfiguracja
translation.yaml(domyślny język, fallback) i lista języków jako parametr, - ✅ teksty przez
|transz kluczami semantycznymi i plikami YAML, - ✅ wyodrębnianie kluczy komendą
translation:extract, - ✅ tłumaczenia z argumentami (
%name%) i liczbą mnogą przez ICU (zamiasttranschoice), - ✅ przełącznik języka przez sesję (
LocaleController+LocaleSubscriber) i dropdown Bootstrap 5.
W Dniu 15 napiszemy testy jednostkowe (PHPUnit) — dla encji, serwisów i repozytoriów.