Rozdział 6: Kategorie i tagi
Mamy listę i stronę artykułu. Teraz pozwolimy filtrować listę po kategorii i tagu: /blog/category/{slug}
i /blog/tag/{slug}. Dobra wiadomość — zapytanie z filtrowaniem (findPublishedQuery) napisaliśmy już
w Rozdziale 4, więc tu głównie dodamy dwie akcje i zamienimy kategorie/tagi w klikalne linki.
Stan wejściowy: działająca lista (Rozdz. 4) i strona artykułu (Rozdz. 5). Sidebar pokazuje kategorie i tagi jako tekst — teraz je „ożywimy”.
Strony kategorii
Akcja category() działa jak index(), ale zawęża listę do jednej kategorii. Kategorię rozwiązujemy po
slug-u (404, gdy nie istnieje) i przekazujemy jej ID do paginate():
// src/Controller/BlogController.php
#[Route('/category/{slug}', name: 'category')]
#[Route('/category/{slug}/page/{page}', name: 'category_page', requirements: ['page' => '\d+'])]
public function category(string $slug, int $page = 1): Response
{
$category = $this->categories->findOneBy(['slug' => $slug]);
if (null === $category) {
throw new NotFoundHttpException(sprintf('Kategoria "%s" nie istnieje.', $slug));
}
return $this->render('blog/index.html.twig', [
'pager' => $this->paginate($page, $category->getId()),
'categories' => $this->categories->findAll(),
'tags' => $this->tags->findAll(),
'activeCategory' => $category,
'activeTag' => null,
'recentlyViewed' => $this->getRecentlyViewed(),
]);
}
- Reużywamy szablon
blog/index.html.twig— ta sama lista, tylko z innym zapytaniem i ustawionymactiveCategory(przyda się do nagłówka i podświetlenia w sidebarze). paginate($page, $category->getId())— drugi argument tocategoryId(metodapaginate()z Rozdziału 4 już go przyjmuje).
⚠️ Kolejność tras. Te trasy (
/blog/category/...) muszą być przed akcjąshow()(/blog/{slug}) z Rozdziału 5 — inaczej/blog/category/phptrafiłoby doshow()zslug = "category". W kontrolerze: najpierwindex,category,tag, ashowna końcu.
Strony tagów
Analogicznie tag() — filtruje po tagu. Różnica: tag wiąże się z artykułami relacją ManyToMany, więc
w zapytaniu potrzebny jest JOIN (o tym za chwilę). Sama akcja jest bliźniacza:
#[Route('/tag/{slug}', name: 'tag')]
#[Route('/tag/{slug}/page/{page}', name: 'tag_page', requirements: ['page' => '\d+'])]
public function tag(string $slug, int $page = 1): Response
{
$tag = $this->tags->findOneBy(['slug' => $slug]);
if (null === $tag) {
throw new NotFoundHttpException(sprintf('Tag "%s" nie istnieje.', $slug));
}
return $this->render('blog/index.html.twig', [
'pager' => $this->paginate($page, tagId: $tag->getId()),
'categories' => $this->categories->findAll(),
'tags' => $this->tags->findAll(),
'activeCategory' => null,
'activeTag' => $tag,
'recentlyViewed' => $this->getRecentlyViewed(),
]);
}
tagId:przekazujemy jako argument nazwany (pomijamycategoryId, zostawiając jegonull).- Nieistniejący slug (kategorii lub tagu) → 404 (jak dla artykułu).
Filtrowanie przez JOIN
Nie piszemy nowego zapytania — findPublishedQuery() z Rozdziału 4 od początku przyjmuje opcjonalne
categoryId i tagId. Przypomnijmy jego dwa warunkowe fragmenty:
// src/Repository/BlogArticleRepository.php — fragment findPublishedQuery()
if (null !== $categoryId) {
$qb->andWhere('a.category = :category')
->setParameter('category', $categoryId);
}
if (null !== $tagId) {
$qb->join('a.tags', 't') // ManyToMany → potrzebny JOIN
->andWhere('t.id = :tag')
->setParameter('tag', $tagId);
}
- Kategoria to relacja ManyToOne — filtrujemy prostym
a.category = :category(kolumna klucza obcego). - Tag to ManyToMany (tabela pośrednia
blog_article_tags) — samoWHEREnie wystarczy, musimy dołączyć kolekcję tagów artykułu (->join('a.tags', 't')) i dopiero po niej filtrować (t.id = :tag). - Filtry łączą się z bazowym warunkiem
status = publishedprzezandWhere— więc na stronie kategorii/tagu też widać tylko opublikowane artykuły, wciąż z paginacją.
Paginacja i aktywne linki
Ten sam szablon obsługuje teraz trzy konteksty (lista, kategoria, tag), więc paginacja musi kierować na
właściwą trasę. Na górze templates/blog/index.html.twig wyliczamy trasę i parametry:
{% set pageRoute = activeCategory ? 'app_blog_category_page'
: (activeTag ? 'app_blog_tag_page' : 'app_blog_index_page') %}
{% set pageParams = activeCategory ? { slug: activeCategory.slug }
: (activeTag ? { slug: activeTag.slug } : {}) %}
Nagłówek też zależy od kontekstu — zamiast stałego „Blog”:
<h1 class="h3 fw-bold mb-4">
{% if activeCategory %}Kategoria: {{ activeCategory.name }}
{% elseif activeTag %}Tag: #{{ activeTag.name }}
{% else %}Blog{% endif %}
</h1>
Pasek paginacji (z Rozdziału 4) używa teraz pageRoute/pageParams zamiast sztywnego
app_blog_index_page:
{% for p in 1..pager.nbPages %}
<li class="page-item {{ p == pager.currentPage ? 'active' }}">
<a class="page-link" href="{{ path(pageRoute, pageParams|merge({ page: p })) }}">{{ p }}</a>
</li>
{% endfor %}
Trasy kategorii i tagów już istnieją, więc zamieniamy tekst w sidebarze na linki (_sidebar.html.twig —
odwracamy uproszczenie z Rozdziału 4):
{# Kategorie #}
{% for category in categories %}
<li>
<a href="{{ path('app_blog_category', { slug: category.slug }) }}"
class="text-decoration-none {{ activeCategory and activeCategory.id == category.id ? 'fw-bold' }}">
{{ category.name }}
</a>
</li>
{% endfor %}
{# Tagi #}
{% for tag in tags %}
<a href="{{ path('app_blog_tag', { slug: tag.slug }) }}"
class="badge bg-light text-dark text-decoration-none">#{{ tag.name }}</a>
{% endfor %}
I podobnie breadcrumb na stronie artykułu (show.html.twig z Rozdziału 5) — kategoria staje się linkiem:
{% if article.category %}
<li class="breadcrumb-item">
<a href="{{ path('app_blog_category', { slug: article.category.slug }) }}">{{ article.category.name }}</a>
</li>
{% endif %}
Podsumowanie
Blog ma pełną nawigację po treści:
- ✅ strony kategorii (
/blog/category/{slug}) i tagów (/blog/tag/{slug}) — 404 dla nieistniejących slug-ów, - ✅ reużycie szablonu listy z
activeCategory/activeTag(dynamiczny nagłówek, podświetlenie w sidebarze), - ✅ filtrowanie przez
findPublishedQuery()— ManyToOne po kolumnie, ManyToMany przezJOINna tagach, zawsze w obrębiepublishedi z paginacją, - ✅ paginacja zależna od kontekstu (
pageRoute/pageParams) oraz aktywne linki kategorii/tagów w sidebarze i breadcrumbie, - ✅ działający stan: klikasz kategorię lub tag i widzisz przefiltrowaną, stronicowaną listę.
W Rozdziale 7 dodamy komentarze — encję z Rozdziału 3 ożywimy formularzem, zapisem, wątkami (odpowiedzi) i podstawową moderacją.