6

Rozdział 6 z 21

Kategorie i tagi

Opublikowany

Dodajemy strony kategorii i tagów — listy artykułów zawężone do wybranej kategorii lub tagu. Filtrowanie realizujemy dodatkowym JOIN-em w repozytorium, z zachowaniem paginacji i obsługą 404 dla nieistniejących slug-ów.

Czego się nauczysz

  • Strony kategorii (/blog/category/{slug})
  • Strony tagów (/blog/tag/{slug})
  • Filtrowanie przez dodatkowy JOIN w repozytorium
  • Paginacja stron kategorii i tagów

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 ustawionym activeCategory (przyda się do nagłówka i podświetlenia w sidebarze).
  • paginate($page, $category->getId()) — drugi argument to categoryId (metoda paginate() 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/php trafiłoby do show() z slug = "category". W kontrolerze: najpierw index, category, tag, a show na 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 (pomijamy categoryId, zostawiając jego null).
  • 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) — samo WHERE nie 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 = published przez andWhere — 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 przez JOIN na tagach, zawsze w obrębie published i 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ą.

Narzędzia / paczki

Doctrine QueryBuilder Pagerfanta

Spis treści