14

Dzień 14 z 18

Tłumaczenia

Opublikowany

Udostępniamy interfejs Jobeet po angielsku i polsku. Poznamy filtr |trans z kluczami semantycznymi, pliki tłumaczeń YAML, komendę translation:extract, tłumaczenia ze zmiennymi oraz liczbę mnogą w formacie ICU. Na koniec zbudujemy przełącznik języka oparty na sesji.

Czego się nauczysz

  • Konfiguracja domyślnego locale, fallbacku i listy języków
  • Tłumaczenia w Twig z kluczami semantycznymi: {{ 'key'|trans }}
  • Pliki tłumaczeń YAML (messages.<locale>.yaml)
  • Wyodrębnianie kluczy komendą translation:extract
  • Format ICU — liczba mnoga i zmienne (%name%)
  • Przełącznik języka przez sesję (LocaleController + LocaleSubscriber)

Co zmieniło się w Symfony 8 vs 4.2

  • Format YAML zamiast XLIFF (jak reszta projektu)
  • Komenda translation:extract zamiast translation:update
  • Format ICU ({n, plural, ...}) zamiast usuniętego transchoice
  • Przełącznik przez sesję i Bootstrap 5 zamiast prefiksu URL i jQuery/Encore

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 (nie translation:update) oraz z formatu ICU dla liczby mnogiej (dawny transchoice został 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
  • --force zapisuje nowe klucze do plików,
  • --clean usuwa klucze, których już nie ma w szablonach.

Zmiana vs Symfony 4.2: komenda nazywa się teraz translation:extract (dawniej translation: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ż serwis LocaleSwitcher do 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 |trans z kluczami semantycznymi i plikami YAML,
  • ✅ wyodrębnianie kluczy komendą translation:extract,
  • ✅ tłumaczenia z argumentami (%name%) i liczbą mnogą przez ICU (zamiast transchoice),
  • ✅ 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.

Narzędzia / paczki

symfony/translation ICU Bootstrap 5

Spis treści