Rozdział 21: SEO
Ostatni rozdział. Domykamy blog od strony SEO: wygenerujemy sitemap XML (z artykułami i ich tłumaczeniami), przypomnimy hreflang i canonical z Rozdziału 14 oraz dodamy meta tagi (description, Open Graph, Twitter Card) na stronie artykułu.
Stan wejściowy: kompletny, wielojęzyczny blog. Tagi
hreflang/canonicalmamy już z Rozdziału 14 — tu dokładamy sitemap i meta tagi.
Sitemap XML
Wyszukiwarki lubią dostać listę wszystkich URL-i w formacie sitemap.xml. Generujemy go kontrolerem zwracającym XML — z artykułami i ich opublikowanymi tłumaczeniami:
// src/Controller/BlogSitemapController.php
#[Route('/sitemap.xml', name: 'app_blog_sitemap', defaults: ['_format' => 'xml'])]
public function sitemap(BlogArticleRepository $articles): Response
{
$published = $articles->findBy(['status' => BlogArticle::STATUS_PUBLISHED], ['publishedAt' => 'DESC']);
$response = $this->render('blog/sitemap.xml.twig', ['articles' => $published]);
$response->headers->set('Content-Type', 'application/xml');
return $response;
}
Szablon blog/sitemap.xml.twig wypisuje każdy artykuł (URL + data modyfikacji) oraz jego tłumaczenia jako
<xhtml:link hreflang> — to sitemap świadomy wielu języków:
{% apply spaceless %}
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
{% for article in articles %}
<url>
<loc>{{ url('app_blog_show', {slug: article.slug}) }}</loc>
<lastmod>{{ article.updatedAt|date('Y-m-d') }}</lastmod>
<xhtml:link rel="alternate" hreflang="pl" href="{{ url('app_blog_show', {slug: article.slug}) }}"/>
{% for t in article.translations if t.status == 'published' and t.slug %}
<xhtml:link rel="alternate" hreflang="{{ t.locale }}"
href="{{ url('app_blog_show', {slug: t.slug}) }}"/>
{% endfor %}
</url>
{% endfor %}
</urlset>
{% endapply %}
/sitemap.xml— standardowa ścieżka; podajesz ją w Google Search Console i wrobots.txt.<lastmod>zupdatedAt— mówi robotom, kiedy treść się zmieniła.<xhtml:link hreflang>— te same wersje językowe co w<head>, ale w sitemapie (Google zaleca oba miejsca).
hreflang i canonical (przypomnienie)
Najważniejsze tagi SEO dla wielojęzyczności zbudowaliśmy już w Rozdziale 14 — w <head> strony artykułu:
{% set selfSlug = translation ? translation.slug : article.slug %}
<link rel="canonical" href="{{ url('app_blog_show', {slug: selfSlug}) }}">
<link rel="alternate" hreflang="pl" href="{{ url('app_blog_show', {slug: article.slug}) }}">
{% 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 kanoniczny URL tej wersji językowej (bez duplikatów).hreflang+x-default— mapa wersji językowych dla Google.
Meta tagi: title i description
Każda strona potrzebuje unikalnego <title> i meta description (to on często ląduje w wynikach
wyszukiwania). Description budujemy z zajawki artykułu:
{# blog/show.html.twig — w bloku head #}
{% block title %}{{ displayTitle }}{% endblock %}
{% block metaDescription %}
<meta name="description" content="{{ (displayExcerpt ?? displayContent)|striptags|slice(0, 160) }}">
{% endblock %}
|striptags— usuwa HTML/Markdown-owy narzut, zostawiając czysty tekst.|slice(0, 160)— description ~160 znaków (tyle pokazuje Google).displayExcerpt ?? displayContent— jeśli brak zajawki, bierzemy początek treści (z tłumaczenia lub oryginału —display*z Rozdziału 14).
Open Graph i Twitter Card
Żeby link do artykułu ładnie wyglądał po wklejeniu na Facebooka/LinkedIn/X, dodajemy tagi Open Graph i Twitter Card:
{# blog/show.html.twig — head #}
<meta property="og:type" content="article">
<meta property="og:title" content="{{ displayTitle }}">
<meta property="og:description" content="{{ (displayExcerpt ?? displayContent)|striptags|slice(0, 160) }}">
<meta property="og:url" content="{{ url('app_blog_show', {slug: selfSlug}) }}">
{% set ogImage = article.media|filter(m => m.type == 'image')|first %}
{% if ogImage %}
<meta property="og:image" content="{{ absolute_url('/' ~ ogImage.path) }}">
{% endif %}
<meta name="twitter:card" content="summary_large_image">
og:image— bierzemy pierwszy obraz z galerii (BlogMedia, Rozdział 10);absolute_url()daje pełny URL (OG wymaga bezwzględnego).twitter:card = summary_large_image— duży podgląd z obrazkiem na X.- OG i description korzystają z
display*(Rozdział 14), więc każda wersja językowa ma własny podgląd.
robots.txtwskaż na sitemap: dodaj linięSitemap: https://twoja-domena.pl/sitemap.xml, żeby roboty znalazły mapę.
Podsumowanie
Blog jest zoptymalizowany pod wyszukiwarki:
- ✅ sitemap XML (
/sitemap.xml) z artykułami,lastmodihreflangdla tłumaczeń, - ✅ hreflang + canonical (z Rozdziału 14) — kanoniczne URL-e i mapa języków,
- ✅ meta tagi — unikalny
titleidescription(z zajawki/treści,striptags, ~160 znaków), - ✅ Open Graph + Twitter Card — ładny podgląd linku,
og:imagez galerii, per język, - ✅ działający stan:
/sitemap.xmlzwraca poprawny XML, a strona artykułu ma komplet tagów SEO.
To koniec 🎉
Przeszliśmy pełną drogę budowy bloga na Symfony 8 — w pięciu częściach, 21 rozdziałach:
- I — Blog podstawowy: instalacja, fundament (User), encje, lista, artykuł, kategorie/tagi, komentarze, panel admina,
- II — Bogata treść i media: Markdown z kolorowaniem kodu, obrazki, wideo/audio, upload, PDF,
- III — Wielojęzyczność: tłumaczenia artykułów i kategorii, slug per język, generowanie AI,
- IV — API: publiczne REST API z DTO i OpenAPI oraz API admina z tokenem i rate limitingiem,
- V — Jakość i wdrożenie: testy jednostkowe i funkcjonalne, deployment i SEO.
Masz kompletny, wielojęzyczny, przetestowany i gotowy na produkcję blog. Powodzenia we własnych projektach!