4

Rozdział 4 z 21

Lista artykułów z paginacją

Opublikowany

Budujemy stronę główną bloga: listę opublikowanych artykułów z paginacją. Poznamy dwuwarstwową architekturę kontrolerów, zapytanie zwracające obiekt Query, paginację przez Pagerfanta (QueryAdapter) oraz sidebar z kategoriami i tagami.

Czego się nauczysz

  • BlogController i trasy listy (/blog, /blog/page/{n})
  • Repozytorium: findPublishedQuery() zwracające Query
  • Paginacja przez Pagerfanta + QueryAdapter
  • Szablon listy artykułów (karty)
  • Sidebar: kategorie i tagi

Rozdział 4: Lista artykułów z paginacją

Czas na pierwszą widoczną stronę bloga. Zbudujemy listę opublikowanych artykułów pod /blog, z paginacją (Pagerfanta) i sidebarem kategorii oraz tagów. Po tym rozdziale wejdziesz na http://localhost:8090/blog i zobaczysz artykuły z fixtures.

Stan wejściowy: masz encje i dane testowe z Rozdziału 3. Teraz dokładamy warstwę web.


Kontroler webowy (BlogController)

W tym projekcie mamy dwie warstwy kontrolerów: src/Controller/ renderuje szablony Twig (front i panel), a src/Api/Controller/ zwraca JSON (Część IV). Zaczynamy od front-owego BlogController.

Jedna metoda index() obsługuje dwie trasy — stronę pierwszą (/blog) i kolejne (/blog/page/{n}):

// src/Controller/BlogController.php
namespace App\Controller;

use App\Entity\BlogArticle;
use App\Repository\BlogArticleRepository;
use App\Repository\BlogCategoryRepository;
use App\Repository\BlogTagRepository;
use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Exception\OutOfRangeCurrentPageException;
use Pagerfanta\Pagerfanta;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/blog', name: 'app_blog_')]
class BlogController extends AbstractController
{
    private const PER_PAGE = 6;

    public function __construct(
        private readonly BlogArticleRepository $articles,
        private readonly BlogCategoryRepository $categories,
        private readonly BlogTagRepository $tags,
    ) {}

    #[Route('', name: 'index')]
    #[Route('/page/{page}', name: 'index_page', requirements: ['page' => '\d+'])]
    public function index(int $page = 1): Response
    {
        return $this->render('blog/index.html.twig', [
            'pager'          => $this->paginate($page),
            'categories'     => $this->categories->findAll(),
            'tags'           => $this->tags->findAll(),
            'activeCategory' => null,
            'activeTag'      => null,
        ]);
    }
}
  • Prefiks trasy #[Route('/blog', name: 'app_blog_')] na klasie — nazwy tras składają się w app_blog_index, app_blog_index_page.
  • requirements: ['page' => '\d+'] — {page} przyjmuje tylko cyfry, więc /blog/page/abc nie trafi tu (i dostanie 404).
  • Wstrzykujemy trzy repozytoria przez konstruktor. activeCategory/activeTag są na razie null — przydadzą się w Rozdziale 6 (filtrowanie), gdzie ta sama metoda posłuży też stronom kategorii.

Sidebar „ostatnio oglądane” dołożymy w Rozdziale 5 (wymaga sesji) — tu skupiamy się na liście.


Zapytanie opublikowanych artykułów

Repozytorium zwraca obiekt Query (a nie tablicę), bo Pagerfanta sama dobierze LIMIT/OFFSET dla bieżącej strony — nie chcemy pobierać wszystkich artykułów naraz.

// src/Repository/BlogArticleRepository.php
use Doctrine\ORM\Query;

public function findPublishedQuery(?int $categoryId = null, ?int $tagId = null): Query
{
    $qb = $this->createQueryBuilder('a')
        ->where('a.status = :status')
        ->setParameter('status', BlogArticle::STATUS_PUBLISHED)
        ->orderBy('a.publishedAt', 'DESC');

    if (null !== $categoryId) {
        $qb->andWhere('a.category = :category')
            ->setParameter('category', $categoryId);
    }

    if (null !== $tagId) {
        $qb->join('a.tags', 't')
            ->andWhere('t.id = :tag')
            ->setParameter('tag', $tagId);
    }

    return $qb->getQuery();
}
  • Filtrujemy po statusie (published — stała z Rozdziału 3) i sortujemy malejąco po publishedAt.
  • Parametry categoryId/tagId są opcjonalne — dziś przekazujemy null, ale ta sama metoda obsłuży filtrowanie kategorii i tagów w Rozdziale 6 (dodatkowy JOIN po tagach).
  • getQuery() zwraca Query bez wykonania — to klucz do współpracy z Pagerfantą.

Paginacja przez Pagerfanta

Paginację robimy przez Pagerfanta z adapterem Doctrine ORM (QueryAdapter). Adapter policzy COUNT i pobierze tylko wiersze bieżącej strony:

use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Pagerfanta;
use Pagerfanta\Exception\OutOfRangeCurrentPageException;

/** @return Pagerfanta<BlogArticle> */
private function paginate(int $page, ?int $categoryId = null, ?int $tagId = null): Pagerfanta
{
    $pager = new Pagerfanta(new QueryAdapter($this->articles->findPublishedQuery($categoryId, $tagId)));
    $pager->setMaxPerPage(self::PER_PAGE);

    try {
        $pager->setCurrentPage($page);
    } catch (OutOfRangeCurrentPageException) {
        throw new NotFoundHttpException();
    }

    return $pager;
}
  • setMaxPerPage(6) — sześć artykułów na stronę (stała PER_PAGE).
  • Przekroczenie zakresu = 404. Wejście na /blog/page/999, gdy stron jest mniej, rzuca OutOfRangeCurrentPageException — łapiemy go i zamieniamy na NotFoundHttpException (czysty 404 zamiast błędu 500).

Jeśli nie masz jeszcze paczki:

docker compose exec app composer require babdev/pagerfanta-bundle

Szablon listy (karty)

Szablon templates/blog/index.html.twig iteruje po pager (Pagerfanta oddaje wiersze bieżącej strony) i renderuje karty. Pod spodem — pasek paginacji Bootstrap:

{% extends 'base.html.twig' %}

{% block title %}Blog{% endblock %}

{% block body %}
<div class="container py-5">
    <div class="row g-4">

        {# Lewa kolumna — lista artykułów #}
        <div class="col-lg-8">
            <h1 class="h3 fw-bold mb-4">Blog</h1>

            {% for article in pager %}
                <article class="card border-0 shadow-sm mb-3">
                    <div class="card-body">
                        <h2 class="h5 mb-1">{{ article.title }}</h2>
                        <p class="text-muted small mb-2">
                            {{ article.publishedAt|date('d.m.Y') }}
                            {% if article.category %} · {{ article.category.name }}{% endif %}
                        </p>
                        <p class="mb-0">{{ article.excerpt }}</p>
                    </div>
                </article>
            {% else %}
                <p class="text-muted">Brak artykułów.</p>
            {% endfor %}

            {# Paginacja #}
            {% if pager.haveToPaginate %}
                <nav class="mt-4">
                    <ul class="pagination">
                        <li class="page-item {{ not pager.hasPreviousPage ? 'disabled' }}">
                            <a class="page-link" href="{{ path('app_blog_index_page', { page: pager.previousPage|default(1) }) }}">←</a>
                        </li>
                        {% for p in 1..pager.nbPages %}
                            <li class="page-item {{ p == pager.currentPage ? 'active' }}">
                                <a class="page-link" href="{{ path('app_blog_index_page', { page: p }) }}">{{ p }}</a>
                            </li>
                        {% endfor %}
                        <li class="page-item {{ not pager.hasNextPage ? 'disabled' }}">
                            <a class="page-link" href="{{ path('app_blog_index_page', { page: pager.nextPage|default(pager.nbPages) }) }}">→</a>
                        </li>
                    </ul>
                </nav>
            {% endif %}
        </div>

        {# Prawa kolumna — sidebar #}
        <div class="col-lg-4">
            {{ include('blog/_sidebar.html.twig') }}
        </div>

    </div>
</div>
{% endblock %}

Tytuł zostawiamy na razie jako tekst — w Rozdziale 5, gdy powstanie trasa app_blog_show, zamienimy go w link do strony artykułu. (Twig path() do jeszcze nieistniejącej trasy rzuciłby wyjątek i cała lista zwróciłaby 500 — dlatego nie linkujemy „na wyrost”.) Najważniejsze metody Pagerfanty użyte wyżej:

Metoda Zwraca
pager (iteracja) artykuły bieżącej strony
pager.haveToPaginate true, gdy stron jest więcej niż 1
pager.nbPages / pager.currentPage liczba stron / bieżąca strona
pager.hasPreviousPage / hasNextPage czy jest poprzednia / następna

Sidebar: kategorie i tagi

Sidebar wydzielamy do partiala templates/blog/_sidebar.html.twig — wykorzystamy go też na stronach kategorii i tagów (Rozdział 6):

<div class="card border-0 shadow-sm mb-4">
    <div class="card-body">
        <h2 class="h6 text-uppercase text-muted mb-3">Kategorie</h2>
        <ul class="list-unstyled mb-0 d-flex flex-column gap-1">
            {% for category in categories %}
                <li class="{{ activeCategory and activeCategory.id == category.id ? 'fw-bold' }}">{{ category.name }}</li>
            {% endfor %}
        </ul>
    </div>
</div>

<div class="card border-0 shadow-sm">
    <div class="card-body">
        <h2 class="h6 text-uppercase text-muted mb-3">Tagi</h2>
        <div class="d-flex flex-wrap gap-2">
            {% for tag in tags %}
                <span class="badge bg-light text-dark">#{{ tag.name }}</span>
            {% endfor %}
        </div>
    </div>
</div>

Kategorie i tagi pokazujemy na razie jako tekst — trasy app_blog_category i app_blog_tag powstaną w Rozdziale 6 i wtedy zamienimy je w linki (path(...)). Dzięki temu strona /blog renderuje się bez błędu już teraz; nie odwołujemy się do tras, których jeszcze nie ma.


Podsumowanie

Blog ma pierwszą działającą stronę:

  • ✅ BlogController::index na dwóch trasach (/blog, /blog/page/{n}) z requirements na {page},
  • ✅ findPublishedQuery() zwracające Query (filtr published, sort po publishedAt),
  • ✅ paginacja przez Pagerfanta (QueryAdapter, PER_PAGE = 6, przekroczenie zakresu → 404),
  • ✅ szablon listy z kartami i paskiem paginacji + sidebar kategorii/tagów (partial do reużycia),
  • ✅ działający stan: wejdź na http://localhost:8090/blog — widzisz opublikowane artykuły z fixtures.

W Rozdziale 5 zbudujemy stronę artykułu (/blog/{slug}) — render treści z Markdown, licznik odsłon i widget „ostatnio oglądane” w sesji.

Narzędzia / paczki

Pagerfanta QueryAdapter Twig

Spis treści