Rozdział 14: Tłumaczenia artykułów
Zaczynamy Część III — Wielojęzyczność. Udostępnimy artykuły w wielu językach wzorcem Translation Table
Pattern: polski to oryginał na BlogArticle, a pozostałe języki trzymamy w osobnej tabeli. Slug będzie
determinował język, a dla SEO dodamy tagi hreflang i canonical.
Stan wejściowy: strona artykułu z Rozdziału 5. Tu dokładamy encję tłumaczenia, rozwiązywanie slug-a po języku i przełączanie locale.
Wzorzec Translation Table
Zamiast dokładać kolumny per język do blog_articles (title_en, title_de, …), trzymamy oryginał
(polski) na BlogArticle, a każde tłumaczenie jako osobny wiersz w tabeli blog_article_translation:
blog_articles (PL — oryginał)
└─ blog_article_translation (en, de, fr, …) — po jednym wierszu na język
Zalety:
- Dowolnie wiele języków bez zmiany schematu (dodanie języka = nowy wiersz, nie nowa kolumna).
- Każde tłumaczenie ma własny status (
draft/published) i własny slug — możesz opublikować wersję angielską później niż polską. - Oryginał zostaje nietknięty; tłumaczenia są „nakładką”.
Encja BlogArticleTranslation
Jedno tłumaczenie = jeden język jednego artykułu. Para (article, locale) jest unikalna, a każdy slug
tłumaczenia też jest unikalny (bo trafia do URL-a):
// src/Entity/BlogArticleTranslation.php
#[ORM\Entity(repositoryClass: BlogArticleTranslationRepository::class)]
#[ORM\Table(name: 'blog_article_translation')]
#[ORM\UniqueConstraint(name: 'uniq_bat_article_locale', fields: ['article', 'locale'])]
#[ORM\Index(fields: ['locale'], name: 'idx_bat_locale')]
class BlogArticleTranslation
{
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: BlogArticle::class, inversedBy: 'translations')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?BlogArticle $article = null;
#[ORM\Column(length: 10)]
private ?string $locale = null; // 'en', 'de', 'fr', …
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $slug = null; // slug per język → do URL-a
#[ORM\Column(type: 'text', nullable: true)]
private ?string $excerpt = null;
#[ORM\Column(type: 'text')]
private ?string $content = null;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_DRAFT; // niezależny status tłumaczenia
#[ORM\Column]
private bool $isAi = false; // czy wygenerowane przez AI (Rozdział 15)
}
Dokładamy stronę odwrotną do BlogArticle — kolekcję translations odłożoną w Rozdziale 3:
// src/Entity/BlogArticle.php — dodajemy kolekcję tłumaczeń
/** @var Collection<int, BlogArticleTranslation> */
#[ORM\OneToMany(targetEntity: BlogArticleTranslation::class, mappedBy: 'article', cascade: ['remove'])]
#[ORM\OrderBy(['locale' => 'ASC'])]
private Collection $translations;
// w konstruktorze: $this->translations = new ArrayCollection();
Migrujemy tabelę:
docker compose exec app php bin/console make:migration
docker compose exec app php bin/console doctrine:migrations:migrate
Slug determinuje język
Nie używamy prefiksów w URL-u (/en/blog/…). Zamiast tego sam slug mówi, który to język i które
tłumaczenie. Robi to serwis BlogSlugResolver:
// src/Service/BlogSlugResolver.php
public function resolve(string $slug): ?array
{
// 1. Oryginał PL — slug wprost na BlogArticle
$article = $this->articles->findOneBy(['slug' => $slug]);
if (null !== $article) {
return ['article' => $article, 'translation' => null, 'locale' => 'pl'];
}
// 2. Slug tłumaczenia — znajdź tłumaczenie po slug-u
$translation = $this->translations->findOneBySlug($slug);
if (null !== $translation && null !== $translation->getArticle()) {
return [
'article' => $translation->getArticle(),
'translation' => $translation,
'locale' => (string) $translation->getLocale(),
];
}
return null;
}
- Najpierw oryginał — jeśli slug pasuje do
BlogArticle, to wersja polska (locale: pl, bez tłumaczenia). - Potem tłumaczenie — slug znaleziony w
blog_article_translationzwraca artykuł plus to konkretne tłumaczenie i jego język. - Brak dopasowania →
null(kontroler zamieni to na 404).
Render i ustawianie locale
Akcję show() z Rozdziału 5 rozszerzamy o rozwiązywanie po języku. Locale ustawiamy z dopasowania
(LocaleSwitcher + Request::setLocale), a do szablonu przekazujemy artykuł i ewentualne tłumaczenie:
// src/Controller/BlogController.php
use Symfony\Component\Translation\LocaleSwitcher;
#[Route('/{slug}', name: 'show')]
public function show(string $slug, BlogSlugResolver $slugResolver, LocaleSwitcher $localeSwitcher, EntityManagerInterface $em): Response
{
$resolved = $slugResolver->resolve($slug);
if (null === $resolved || BlogArticle::STATUS_PUBLISHED !== $resolved['article']->getStatus()) {
throw new NotFoundHttpException();
}
$article = $resolved['article'];
$translation = $resolved['translation'];
// slug tłumaczenia działa tylko, gdy TO tłumaczenie jest opublikowane
if (null !== $translation && BlogArticleTranslation::STATUS_PUBLISHED !== $translation->getStatus()) {
throw new NotFoundHttpException();
}
// ustaw język z dopasowanego slug-a — cała strona renderuje się w tym języku
$localeSwitcher->setLocale($resolved['locale']);
$this->requestStack->getCurrentRequest()?->setLocale($resolved['locale']);
$article->incrementViews();
$em->flush();
$this->pushRecentlyViewed($article->getId());
return $this->render('blog/show.html.twig', [
'article' => $article,
'translation' => $translation, // null dla wersji PL
]);
}
W szablonie tłumaczenie nadpisuje pola artykułu, gdy istnieje:
{# translation overrides article fields when available #}
{% set displayTitle = translation ? translation.title : article.title %}
{% set displayContent = translation ? translation.content : article.content %}
{% set displayExcerpt = translation ? translation.excerpt : article.excerpt %}
<h1>{{ displayTitle }}</h1>
<div class="blog-content">{{ displayContent|markdown }}</div>
Dzięki temu jedna trasa /blog/{slug} obsługuje wszystkie języki — bez prefiksów i duplikacji akcji.
SEO: hreflang i canonical
Żeby wyszukiwarki wiedziały, że to ta sama treść w różnych językach, w <head> dodajemy canonical
(dla bieżącej wersji) i hreflang (dla każdej dostępnej wersji):
{% set selfSlug = translation ? translation.slug : article.slug %}
<link rel="canonical" href="{{ url('app_blog_show', {slug: selfSlug}) }}">
{# wersja oryginalna (PL) #}
<link rel="alternate" hreflang="pl" href="{{ url('app_blog_show', {slug: article.slug}) }}">
{# opublikowane tłumaczenia #}
{% for t in article.translations if t.status == 'published' and t.slug %}
<link rel="alternate" hreflang="{{ t.locale }}" href="{{ url('app_blog_show', {slug: t.slug}) }}">
{% endfor %}
<link rel="alternate" hreflang="x-default" href="{{ url('app_blog_show', {slug: article.slug}) }}">
canonicalwskazuje na URL tej wersji językowej (bieżący slug).hreflangwymienia wszystkie wersje — dzięki temu Google poda użytkownikowi właściwy język i nie potraktuje tłumaczeń jako duplikatów.x-default— wersja domyślna (tu oryginał PL) dla języków spoza listy.
Przełącznik języka w menu można zbudować z tej samej kolekcji
article.translations— link na każdy opublikowany język, z fallbackiem na oryginał, gdy tłumaczenia brak.
Podsumowanie
Artykuły są wielojęzyczne:
- ✅ Translation Table Pattern — oryginał PL na
BlogArticle, języki wblog_article_translation(dowolnie wiele, własny status i slug), - ✅ encja
BlogArticleTranslation(unikalnearticle+locale, unikalny slug) + kolekcjatranslationsnaBlogArticle, - ✅
BlogSlugResolver— slug determinuje język (oryginał vs tłumaczenie), bez prefiksów w URL, - ✅
show()ustawia locale (LocaleSwitcher), sprawdza publikację tłumaczenia, a szablon nadpisuje pola tłumaczeniem, - ✅ SEO —
canonical+hreflang(w tymx-default), - ✅ działający stan: wchodzisz na slug tłumaczenia i widzisz artykuł w danym języku, z poprawnym SEO.
W Rozdziale 15 dodamy tłumaczenia kategorii i generowanie tłumaczeń przez AI wraz z panelem tłumaczeń w adminie — domykając Część III.