14

Rozdział 14 z 21

Tłumaczenia artykułów

Opublikowany

Rozszerzamy blog o wielojęzyczność artykułów wzorcem Translation Table. PL to oryginał na BlogArticle, pozostałe języki w blog_article_translation. Slug determinuje język (BlogSlugResolver), locale ustawiane z dopasowania, plus tagi hreflang i canonical dla SEO.

Czego się nauczysz

  • Translation Table Pattern — założenia
  • Encja BlogArticleTranslation (slug/status per język)
  • BlogSlugResolver — slug determinuje język
  • Ustawianie locale z dopasowania (bez prefiksu URL)
  • SEO: hreflang i canonical

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_translation zwraca 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}) }}">
  • canonical wskazuje na URL tej wersji językowej (bieżący slug).
  • hreflang wymienia 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 w blog_article_translation (dowolnie wiele, własny status i slug),
  • ✅ encja BlogArticleTranslation (unikalne article+locale, unikalny slug) + kolekcja translations na BlogArticle,
  • ✅ 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 tym x-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.

Narzędzia / paczki

Translation Table Pattern BlogSlugResolver hreflang

Spis treści