Wielojęzyczność na stronie statycznej — konfiguracja zamiast kodu
symfony,php,statyczna-strona,architekturaCzęść 10 z 10
- 19 lat z WordPressem — dlaczego w końcu zrezygnowałem
- Symfony jako generator stron statycznych — jak działa holas.pl
- Zabezpieczenie jedynego dynamicznego endpointu strony statycznej — formularz kontaktowy
- Drzewo decyzyjne narzędzi dla Claude Code z globalną pamięcią
- Środowisko deweloperskie — od lokalnego devu do produkcji w dwóch kontenerach
- Budowanie holas.pl z AI — Claude Code, MCP i lokalne generowanie obrazów
- 4×100 w Lighthouse Mobile — co daje statyczna strona
- Inżynieria SEO na statycznej stronie — dane strukturalne, karty społecznościowe i sygnały dla crawlerów
- Responsywne obrazy i zaplanowane wpisy na statycznej stronie — rozwiązania w kroku budowania
- Wielojęzyczność na stronie statycznej — konfiguracja zamiast kodu
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/
}
- Domyślny locale — użyj
pathz atrybutu bez zmian:/blog/ - Nadpisanie istnieje — dodaj
/{locale}+ przetłumaczoną ścieżkę z_routes.yaml - 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. security → bezpieczenstwo) 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:
content/_site.yaml— dodaj wpisde:z label, og_locale, date_formatcontent/_routes.yaml— dodaj nadpisaniade:dla przetłumaczonych segmentów trastranslations/messages.de.yaml— niemieckie stringi UI (etykiety nawigacji, tekst przycisków itp.)content/_tags.yaml— niemieckie tłumaczenia tagów- Pliki treści — utwórz
de.mdoboken.mdipl.mddla 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ą.