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ę wapp_blog_index,app_blog_index_page. requirements: ['page' => '\d+']—{page}przyjmuje tylko cyfry, więc/blog/page/abcnie trafi tu (i dostanie 404).- Wstrzykujemy trzy repozytoria przez konstruktor.
activeCategory/activeTagsą na razienull— 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 popublishedAt. - Parametry
categoryId/tagIdsą opcjonalne — dziś przekazujemynull, ale ta sama metoda obsłuży filtrowanie kategorii i tagów w Rozdziale 6 (dodatkowyJOINpo tagach). getQuery()zwracaQuerybez 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łaPER_PAGE).- Przekroczenie zakresu = 404. Wejście na
/blog/page/999, gdy stron jest mniej, rzucaOutOfRangeCurrentPageException— łapiemy go i zamieniamy naNotFoundHttpException(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::indexna dwóch trasach (/blog,/blog/page/{n}) zrequirementsna{page}, - ✅
findPublishedQuery()zwracająceQuery(filtrpublished, sort popublishedAt), - ✅ 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.