To jest część 9 serii o migracji holas.pl z WordPressa na niestandardowy generator stron statycznych oparty na Symfony. Część 8 opisuje responsywne obrazy i zaplanowane wpisy.


Dodanie drugiego języka do strony Symfony zazwyczaj oznacza duplikowanie kontrolerów, zakodowane na sztywno prefiksy URL-i i rozsiane po całym kodzie sprawdzenia locale. holas.pl podchodzi do tego inaczej: konfiguracja locale żyje w jednym pliku YAML, trasy generowane są z własnego atrybutu PHP, a tłumaczenia łączone są przez system plików — nie przez jawne klucze.

Problem z zakodowanymi na sztywno locale

Pierwsza działająca wersja wielojęzyczności holas.pl miała około 30 miejsc z takimi wzorcami:

#[Route('/blog/', name: 'blog_list_en')]
public function listEn(int $page = 1): Response
{
    return $this->renderList('en', $page);
}

#[Route('/pl/wpisy/', name: 'blog_list_pl')]
public function listPl(int $page = 1): Response
{
    return $this->renderList('pl', $page);
}

Każda trasa miała dwie metody — jedną na locale. Właściwa logika kontrolera żyła w prywatnej metodzie render*(); publiczne metody były czystym boilerplate'em ustawiającym locale i delegującym dalej. Siedem kontrolerów × dwa locale = 28 metod nie robiących nic pożytecznego.

Dodanie trzeciego języka wymagałoby dodania 14 kolejnych metod, plus aktualizacji szablonów, listenera locale i każdego miejsca sprawdzającego 'pl' === $locale. Kod się nie skalował.

Jedno źródło prawdy — _site.yaml

Naprawa zaczyna się od centralizacji listy locale. Zamiast rozrzucać wiedzę o locale po plikach PHP, wszystko żyje w content/_site.yaml:

site:
  locales:
    en:
      label: "English"
      og_locale: en_US
      date_format: "M d, Y"
    pl:
      label: "Polski"
      og_locale: pl_PL
      font_preload: fonts/inter-normal-latin-ext.woff2
      date_format: "d.m.Y"

Kolejność ma znaczenie: pierwszy klucz to domyślny locale. Każdy locale niesie własne metadane — og_locale dla tagów Open Graph, date_format do renderowania szablonów, font_preload dla znaków Latin Extended, których potrzebuje tylko polski.

SiteConfigService czyta ten plik raz, cachuje go i udostępnia całej aplikacji:

interface SiteConfigServiceInterface
{
    /** @return string[] Uporządkowane kody locale, pierwszy = domyślny */
    public function getLocales(): array;

    public function getDefaultLocale(): string;

    /** @return array<string, mixed> Konfiguracja pojedynczego locale */
    public function getLocaleConfig(string $locale): array;
}

Każdy kontroler, listener i loader tras wstrzykuje ten interfejs. Żaden kod PHP nie importuje listy locale z framework.yaml ani nie koduje na sztywno ['en', 'pl'].

Własny atrybut — #[LocalizedRoute]

Kluczową innowacją jest własny atrybut PHP, który zastępuje zduplikowane metody:

#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class LocalizedRoute
{
    public function __construct(
        public readonly string $name,
        public readonly string $path,
        public readonly array $requirements = [],
        public readonly array $methods = [],
        public readonly int $priority = 0,
    ) {
    }
}

Atrybut definiuje trasę tylko dla domyślnego locale. Własny LocalizedRouteLoader skanuje wszystkie kontrolery, znajduje atrybuty #[LocalizedRoute] i generuje trasy {name}_{locale} dla każdego skonfigurowanego locale:

#[LocalizedRoute('blog_list', path: '/blog/')]
#[LocalizedRoute('blog_list_paginated', path: '/blog/page/{page}/', requirements: ['page' => '\d+'])]
public function list(string $locale, int $page = 1): Response
{
    // jedna metoda obsługuje wszystkie locale
}

Ta jedna metoda zastępuje dwie listEn() / listPl() z poprzedniej wersji. Loader generuje cztery trasy z dwóch atrybutów: blog_list_en, blog_list_pl, blog_list_paginated_en, blog_list_paginated_pl.

Łącznie we wszystkich kontrolerach: 28 metod stało się 14. Każda usunięta metoda była czystym boilerplate'em.

Rozwiązywanie tras

Loader musi wiedzieć, że /blog/ to angielska ścieżka, a /pl/wpisy/ to polska. Trzystopniowy algorytm to obsługuje:

private function resolvePath(
    string $name, string $defaultPath,
    string $locale, string $defaultLocale,
    array $overrides,
): string {
    if ($locale === $defaultLocale) {
        return $defaultPath;                          // EN: /blog/
    }

    if (isset($overrides[$name][$locale])) {
        return '/'.$locale.$overrides[$name][$locale]; // PL: /pl/wpisy/
    }

    return '/'.$locale.$defaultPath;                   // fallback: /pl/blog/
}
  1. Domyślny locale — użyj path z atrybutu bez zmian: /blog/
  2. Nadpisanie istnieje — dodaj /{locale} + przetłumaczoną ścieżkę z _routes.yaml
  3. Brak nadpisania — dodaj /{locale} + domyślną ścieżkę: /pl/blog/

_routes.yaml — przetłumaczone segmenty ścieżek

Tylko trasy z przetłumaczonymi segmentami URL potrzebują nadpisań. Trasy bez wpisów dostają automatyczny prefiks:

routes:
  blog_list:
    pl: /wpisy/
  blog_list_paginated:
    pl: /wpisy/strona/{page}/
  blog_category:
    pl: /wpisy/{category}/
  blog_archive:
    pl: /archiwum/{year}/{month}/
  contact:
    pl: /kontakt/
  search:
    pl: /szukaj/

blog_tag nie ma wpisu, więc blog_tag_pl dostaje automatyczny prefiks: /pl/tag/{tag}/. Dodanie niemieckiego wymagałoby dodania wpisów de: do tras wymagających tłumaczenia i niczego dla tras, gdzie angielska ścieżka jest odpowiednia.

Dwa wzorce rozwiązywania URL-i

Szablony muszą linkować do stron. Są dwa fundamentalnie różne przypadki:

Typ Wzorzec Przykład
Strukturalne (listingi, szukanie, kontakt, archiwum) path('route_name_' ~ locale) path('blog_list_' ~ locale)
Treść (strony, wpisy, o mnie, prywatność) content_url(directoryKey, locale) content_url('about', locale)

Trasy strukturalne pochodzą z routera — generowane przez LocalizedRouteLoader i mają nazwy {name}_{locale}. URL-e treści pochodzą z pól slug we frontmatter — każdy en.md i pl.md definiuje własny URL.

{# Strukturalne: router zna ścieżkę #}
<a href="{{ path('blog_list_' ~ locale) }}">Blog</a>
<a href="{{ path('contact_' ~ locale) }}">Kontakt</a>

{# Treść: wyszukaj po kluczu katalogu #}
<a href="{{ content_url('about', locale) }}">O mnie</a>
<a href="{{ content_url('privacy-policy', locale) }}">Prywatność</a>

content_url() to własna funkcja Twig, która wyszukuje ContentItem po jego kluczu katalogu — nazwie folderu — i zwraca URL z frontmatter. Zastępuje to stare podejście z zakodowanymi na sztywno slugami per locale w szablonach.

Współlokalizowana treść — system plików jako łącznik tłumaczeń

Pliki treści tego samego wpisu żyją w tym samym katalogu:

content/blog/tutorials/my-post/
    en.md  → slug: "blog/my-post"
    pl.md  → slug: "pl/blog/moj-wpis"
    files/ → obrazy serwowane pod /media/my-post/

Oba pliki w tym samym folderze są automatycznie łączone jako tłumaczenia. Nie potrzeba jawnego pola translation_key. ContentItem::directoryKey() zwraca nazwę folderu ("my-post"), a TranslationMapBuilder używa jej do budowy tablicy wyszukiwania:

public function build(array $trees): array
{
    $map = [];

    foreach ($trees as $locale => $tree) {
        foreach ($tree->getAllItems() as $item) {
            $key = $item->directoryKey();
            if (null === $key || '' === $item->url()) {
                continue;
            }
            $map[$key][$locale] = $item->url();
        }
    }

    return $map;
}

Wynik: $map['my-post']['en'] = '/blog/my-post/', $map['my-post']['pl'] = '/pl/blog/moj-wpis/'. Ta mapa napędza tagi hreflang <link> w nagłówku HTML i przełącznik języka.

Nie każdy wpis potrzebuje obu plików locale. Wpis z samym en.md nie pojawi się na polskich listingach, a przełącznik języka przekieruje na stronę główną drugiego języka.

Przełącznik języka — łańcuch fallbacków

Przełącznik języka wydaje się prosty — link do tej samej strony w innym języku. W praktyce musi obsługiwać częściowe tłumaczenia, strony tagów, strony archiwum i paginowane listingi. Łańcuch fallbacków:

{# 1. Spróbuj mapy tłumaczeń (strona istnieje w innym locale) #}
{% if tk and translation_map[tk][other] is defined %}
    {% set url = translation_map[tk][other] %}
{% endif %}

{# 2. Spróbuj URL podany przez kontroler (strony tagów) #}
{% if url is null and lang_switch_url is defined %}
    {% set url = lang_switch_url %}
{% endif %}

{# 3. Fallback: archiwum, paginowany listing lub strona główna #}
{% if url is null %}
    {% if filter_type is same as('archive') and archive_year is defined %}
        {% set url = path('blog_archive_' ~ other, {year: ..., month: ...}) %}
    {% elseif current_page > 1 %}
        {% set url = path('blog_list_paginated_' ~ other, {page: current_page}) %}
    {% else %}
        {% set url = path('home_' ~ other) %}
    {% endif %}
{% endif %}

Strony tagów wymagają specjalnej obsługi: BlogController tłumaczy slug tagu między locale (np. securitybezpieczenstwo) i sprawdza, czy drugi locale ma jakieś wpisy z tym tagiem. Jeśli tak, przełącznik linkuje do przetłumaczonej strony tagu. Jeśli nie, wraca do listingu wpisów drugiego locale.

Po stronie klienta locale-redirect.js obsługuje detekcję języka przy pierwszej wizycie. Czyta navigator.language, porównuje z listą skonfigurowanych locale (z atrybutu data-locales na <html>) i zapisuje preferencje w ciasteczku. Przy kolejnych wizytach przekierowuje do zapisanej preferencji. Lista locale nie jest zakodowana na sztywno w JavaScript — pochodzi z tej samej konfiguracji _site.yaml, przekazanej przez Twig.

Detekcja locale

LocaleListener działa z priorytetem 8 — po wbudowanych listenerach locale Symfony — i wykrywa locale ze ścieżki URL:

private function detectLocale(string $path): string
{
    $defaultLocale = $this->siteConfig->getDefaultLocale();

    foreach ($this->siteConfig->getLocales() as $locale) {
        if ($locale === $defaultLocale) {
            continue;
        }

        if (str_starts_with($path, '/'.$locale.'/') || '/'.$locale === $path) {
            return $locale;
        }
    }

    return $defaultLocale;
}

Żadnego zakodowanego sprawdzenia /pl/. Iteruje skonfigurowane locale dynamicznie. Dodanie nowego locale do _site.yaml wystarczy, aby listener zaczął go wykrywać.

Dodanie nowego języka — zero zmian w PHP

To jest efekt końcowy. Dodanie niemieckiego do holas.pl wymaga:

  1. content/_site.yaml — dodaj wpis de: z label, og_locale, date_format
  2. content/_routes.yaml — dodaj nadpisania de: dla przetłumaczonych segmentów tras
  3. translations/messages.de.yaml — niemieckie stringi UI (etykiety nawigacji, tekst przycisków itp.)
  4. content/_tags.yaml — niemieckie tłumaczenia tagów
  5. Pliki treści — utwórz de.md obok en.md i pl.md dla wpisów, które powinny istnieć po niemiecku

Zero zmienionych plików PHP. Zero edytowanych szablonów Twig. Zero dodanych metod kontrolera. Loader tras generuje niemieckie trasy automatycznie. Listener locale wykrywa ścieżki /de/. Przełącznik języka renderuje dropdown zamiast pojedynczego linka. Mapa tłumaczeń zawiera niemieckie URL-e.

28 metod kontrolera specyficznych per locale i 30 zakodowanych na sztywno sprawdzeń locale z pierwszej wersji wymagałoby edycji ponad 20 plików, aby dodać niemiecki. Podejście oparte na konfiguracji oznacza edycję 5 plików konfiguracyjnych i tworzenie treści.

Decyzje architektoniczne, które to umożliwiają — SiteConfigService jako jedyne źródło prawdy o locale, LocalizedRouteLoader generujący trasy z atrybutów, współlokalizowana treść jako łącznik tłumaczeń — to decyzje, które wyglądają na over-engineering, gdy masz tylko dwa języki. Nie są. To różnica między "dodanie języka to tydzień pracy" a "dodanie języka to popołudnie z konfiguracją."

Następny wpis opisuje jak baza kodu poprawiała się przez iteracyjne rundy jakości — value objects, interfejsy, ekstrakcja komponentów — z AI obsługującym systematyczną pracę przeglądową.