Rozdział 15: Tłumaczenia kategorii i generowanie AI
Domykamy wielojęzyczność. Do tłumaczeń artykułów (Rozdział 14) dokładamy tłumaczenia kategorii, panel tłumaczeń w adminie, generowanie przez AI oraz przełącznik języka w menu.
Stan wejściowy: tłumaczenia artykułów i
BlogSlugResolver(Rozdział 14). Tu rozszerzamy wzorzec na kategorie i dokładamy narzędzia w adminie.
Tłumaczenia kategorii
Kategorie tłumaczymy tym samym wzorcem co artykuły — osobna tabela blog_category_translation
z nazwą i slug-iem per język:
// src/Entity/BlogCategoryTranslation.php
#[ORM\Entity(repositoryClass: BlogCategoryTranslationRepository::class)]
#[ORM\Table(name: 'blog_category_translation')]
#[ORM\UniqueConstraint(name: 'uniq_bct_category_locale', fields: ['category', 'locale'])]
class BlogCategoryTranslation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: BlogCategory::class, inversedBy: 'translations')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?BlogCategory $category = null;
#[ORM\Column(length: 10)]
private ?string $locale = null;
#[ORM\Column(length: 100)]
private ?string $name = null;
#[ORM\Column(length: 120, unique: true, nullable: true)]
private ?string $slug = null;
}
Dokładamy kolekcję translations do BlogCategory (odłożoną w Rozdziale 3) — z orphanRemoval, bo
tłumaczenia w pełni należą do kategorii:
// src/Entity/BlogCategory.php
/** @var Collection<int, BlogCategoryTranslation> */
#[ORM\OneToMany(targetEntity: BlogCategoryTranslation::class, mappedBy: 'category',
cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $translations;
Do wyświetlania dodajemy na encji metody „display”, które zwracają wersję dla bieżącego języka (z fallbackiem na oryginał):
public function getDisplayName(string $locale): string
{
foreach ($this->translations as $t) {
if ($t->getLocale() === $locale && null !== $t->getName()) {
return $t->getName();
}
}
return (string) $this->name; // fallback: oryginał
}
BlogSlugResolver z Rozdziału 14 ma bliźniaczą metodę resolveCategory($slug) — slug kategorii również
determinuje język strony kategorii.
Panel tłumaczeń w adminie
Tłumaczeniami zarządzamy w osobnym kontrolerze pod /admin/blog/{id}/translations (chronionym ROLE_ADMIN).
Widok główny to macierz języków — dla każdego z obsługiwanych locale pokazujemy status (brak / szkic /
opublikowane) i akcje:
// src/Controller/Admin/BlogArticleTranslationAdminController.php
#[Route('/admin/blog/{id}/translations', name: 'admin_blog_translation_')]
#[IsGranted('ROLE_ADMIN')]
class BlogArticleTranslationAdminController extends AbstractController
{
private const LOCALES = [
'en' => ['label' => 'English', 'flag' => '🇬🇧'],
'de' => ['label' => 'Deutsch', 'flag' => '🇩🇪'],
'fr' => ['label' => 'Français', 'flag' => '🇫🇷'],
'es' => ['label' => 'Español', 'flag' => '🇪🇸'],
'ru' => ['label' => 'Русский', 'flag' => '🇷🇺'],
];
#[Route('', name: 'index')]
public function index(BlogArticle $article, BlogArticleTranslationRepository $repo): Response
{
$byLocale = [];
foreach ($repo->findAllForArticle($article) as $t) {
$byLocale[$t->getLocale()] = $t;
}
return $this->render('admin/blog/translations/index.html.twig', [
'article' => $article,
'locales' => self::LOCALES,
'byLocale' => $byLocale,
]);
}
}
Akcja edit (/{locale}/edit) to zwykły formularz tłumaczenia (tytuł, slug, zajawka, treść, status) — jego
zapis działa jak każdy CRUD z Rozdziału 8.
Generowanie przez AI
Zamiast tłumaczyć ręcznie, generujemy wersję językową modelem AI. Logikę zamykamy w serwisie
BlogTranslationAiService, który woła API modelu (tu Anthropic/Claude) przez HttpClientInterface:
// src/Service/BlogTranslationAiService.php (szkielet)
final readonly class BlogTranslationAiService
{
private const MODEL = 'claude-sonnet-4-6';
public function __construct(
private HttpClientInterface $httpClient,
private EntityManagerInterface $em,
#[Autowire('%env(ANTHROPIC_API_KEY)%')] private string $apiKey,
) {}
public function translate(BlogArticle $article, string $sourceLocale, string $targetLocale, ?User $triggeredBy): BlogArticleTranslation
{
$source = $this->resolveSource($article, $sourceLocale); // tekst źródłowy (PL lub inne tłum.)
$result = $this->callAnthropic($source, $targetLocale); // → tytuł/slug/zajawka/treść
$translation = /* utwórz lub zaktualizuj BlogArticleTranslation */;
$translation->setIsAi(true)->setAiModel(self::MODEL); // oznacz jako AI
$this->em->flush();
return $translation;
}
}
Akcja kontrolera jest JSON-owa (POST /{locale}/ai) — front wywołuje ją AJAX-em i wypełnia formularz
edycji wygenerowaną treścią:
#[Route('/{locale}/ai', name: 'ai', methods: ['POST'])]
public function aiGenerate(BlogArticle $article, string $locale, Request $request, BlogTranslationAiService $ai): JsonResponse
{
$sourceLocale = $request->request->getString('source_locale', 'pl');
try {
$t = $ai->translate($article, $sourceLocale, $locale, $this->getUser());
return $this->json([
'ok' => true, 'title' => $t->getTitle(), 'slug' => $t->getSlug(),
'excerpt' => $t->getExcerpt(), 'content' => $t->getContent(),
'isAi' => true, 'model' => $t->getAiModel(),
]);
} catch (\Throwable $e) {
return $this->json(['ok' => false, 'error' => $e->getMessage()], 500);
}
}
isAi+aiModel— na encji tłumaczenia zapisujemy, że powstało z AI i którym modelem. Na froncie pokazujemy przy takim artykule notkę „to tłumaczenie zostało wygenerowane przez AI”.- Klucz API z ENV (
ANTHROPIC_API_KEY) — nigdy w kodzie; wstrzykujemy przez#[Autowire('%env(...)%')]. - Wynik trafia do formularza
edit, więc admin może go poprawić przed publikacją — AI to punkt startowy, nie ostateczna wersja.
Przełącznik języka w menu
Skoro artykuł zna swoje tłumaczenia (kolekcja article.translations), możemy zbudować przełącznik języka
w menu — link na każdą opublikowaną wersję, z fallbackiem na oryginał, gdy tłumaczenia brak:
{% for t in article.translations if t.status == 'published' and t.slug %}
<a class="dropdown-item {{ t.locale == app.request.locale ? 'active' }}"
href="{{ path('app_blog_show', { slug: t.slug }) }}">{{ t.locale|upper }}</a>
{% endfor %}
Przełącznik prowadzi wprost na slug danego języka — a BlogSlugResolver (Rozdział 14) rozpozna język
z tego slug-a i wyrenderuje stronę we właściwej wersji. Do programistycznej zmiany języka Symfony ma też
serwis LocaleSwitcher (użyliśmy go w show()).
Podsumowanie
Blog jest w pełni wielojęzyczny:
- ✅ tłumaczenia kategorii (
BlogCategoryTranslation,resolveCategory, metodygetDisplayName/Slugz fallbackiem), - ✅ panel tłumaczeń w adminie (
/admin/blog/{id}/translations) — macierz języków + formularz edycji, - ✅ generowanie AI (
BlogTranslationAiServiceprzezHttpClientInterface, klucz z ENV, akcja JSON, flagaisAi/aiModel+ notka na froncie), - ✅ przełącznik języka oparty na
article.translations(+LocaleSwitcher), - ✅ działający stan: dodajesz/generujesz tłumaczenie w adminie, przełącznik prowadzi na slug języka, a strona renderuje się w danym języku z poprawnym SEO (Rozdział 14).
To koniec Części III — blog obsługuje wiele języków (artykuły i kategorie, slug per język, hreflang, generowanie AI).
W Części IV (Rozdziały 16–17) udostępnimy blog przez REST API — publiczne endpointy z DTO i dokumentacją OpenAPI oraz API admina z uwierzytelnianiem tokenem i rate limitingiem. Zaczniemy w Rozdziale 16.