Dzień 7: Strona kategorii
Zbudujemy stronę kategorii zgodnie z historią F2 z Dnia 2: „użytkownik widzi listę wszystkich ofert z kategorii, posortowaną według daty i podzieloną na strony po 20 ofert na stronę”. Po drodze dodamy slug do kategorii (czytelne URL-e), wydzielimy współdzielony fragment szablonu i wdrożymy paginację.
Slug generujemy biblioteką cocur/slugify (jak w module bloga), a paginację realizujemy przez Pagerfanta z
QueryAdapter— zgodnie ze standardem projektu.
Slug kategorii
Chcemy adresów typu /category/programming zamiast /category/2. Potrzebujemy pola slug w encji
Category, generowanego automatycznie z nazwy. Dodaj pole i lifecycle callback w src/Entity/Category.php:
use Cocur\Slugify\Slugify;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
#[ORM\Table(name: 'categories')]
#[ORM\HasLifecycleCallbacks]
class Category
{
// ...
#[ORM\Column(length: 128, unique: true)]
private ?string $slug = null;
public function getSlug(): ?string
{
return $this->slug;
}
#[ORM\PrePersist]
public function computeSlug(): void
{
if (null !== $this->name) {
$this->slug = (new Slugify())->slugify($this->name);
}
}
}
Dzięki callbackowi #[ORM\PrePersist] slug tworzy się automatycznie przy zapisie — nie musimy nigdzie
wołać setSlug(). Fixtures z Dnia 3 nie wymagają zmian: przy flush() slug policzy się sam.
Zmiana vs Symfony 4.2: oryginał instalował StofDoctrineExtensionsBundle i używał adnotacji
@Gedmo\Slug. My korzystamy z lekkiej biblioteki cocur/slugify (już obecnej w projekcie) w lifecycle callbacku — bez dodatkowego bundla i konfiguracji. W panelu admina slug ustawiamy analogicznie przy zapisie (jak w module bloga).
Migracja i przeładowanie danych
Wygeneruj migrację dodającą kolumnę slug:
docker compose exec app php bin/console make:migration
Istniejące kategorie nie mają jeszcze slugów, a kolumna jest NOT NULL i unique. W środowisku
deweloperskim najprościej odtworzyć bazę od zera i wczytać dane ponownie (slug policzy się automatycznie):
docker compose exec app php bin/console doctrine:database:drop --force --if-exists
docker compose exec app php bin/console doctrine:database:create
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
docker compose exec app php bin/console doctrine:fixtures:load --no-interaction
⚠️ To usuwa wszystkie dane — rób tak tylko w dev. Sprawdź wynik:
docker compose exec app php bin/console dbal:run-sql 'SELECT id, name, slug FROM categories'.
Kontroler kategorii
Utwórz src/Controller/CategoryController.php. Trasa zawiera slug, a Symfony samo wczyta kategorię
(EntityValueResolver dopasuje {slug} do pola slug):
<?php
namespace App\Controller;
use App\Entity\Category;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class CategoryController extends AbstractController
{
#[Route('/category/{slug}', name: 'category_show', methods: ['GET'])]
public function show(Category $category): Response
{
return $this->render('category/show.html.twig', [
'category' => $category,
]);
}
}
Współdzielony szablon tabeli
Tabela ofert na stronie głównej i na stronie kategorii jest taka sama — nie duplikujmy jej. Wydziel
fragment do templates/job/_table.html.twig:
<table class="table table-hover text-center align-middle">
<thead>
<tr>
<th class="text-center">Lokalizacja</th>
<th class="text-center">Stanowisko</th>
<th class="text-center">Firma</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>{{ job.location }}</td>
<td><a href="{{ path('job_show', { id: job.id }) }}">{{ job.position }}</a></td>
<td>{{ job.company }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-muted">Brak ofert.</td></tr>
{% endfor %}
</tbody>
</table>
Fragment iteruje po zmiennej jobs. Dołączamy go instrukcją include ze słowami kluczowymi with i
only (przekazujemy tylko zmienną jobs).
Szablon templates/category/show.html.twig:
{% extends 'base.html.twig' %}
{% block title %}Oferty w kategorii {{ category.name }}{% endblock %}
{% block body %}
<h1 class="h4 mb-3">{{ category.name }}</h1>
{% include 'job/_table.html.twig' with { jobs: category.activeJobs } only %}
{% endblock %}
Zaktualizuj też stronę główną templates/job/list.html.twig, aby korzystała z fragmentu i linkowała do
kategorii:
{% extends 'base.html.twig' %}
{% block title %}Jobeet — oferty pracy{% endblock %}
{% block body %}
{% for category in categories %}
<h2 class="h4 mt-4">
<a href="{{ path('category_show', { slug: category.slug }) }}">{{ category.name }}</a>
</h2>
{% include 'job/_table.html.twig' with {
jobs: category.activeJobs|slice(0, max_jobs_on_homepage)
} only %}
{% endfor %}
{% endblock %}
Teraz z listy kategorii na stronie głównej można przejść do konkretnej kategorii.
Zmiana vs Symfony 4.2: to samo podejście co w oryginale (include +
with/only), tylko nazwy tras są zgodne z naszą konwencją (job_show,category_show), a szablon używa Bootstrap 5.
Paginacja (Pagerfanta)
Historia F2 wymaga podziału na strony po 20 ofert. Użyjemy Pagerfanta — biblioteki paginacji, którą
projekt stosuje też w blogu. Paginator przyjmuje zapytanie (obiekt Query), a nie gotowy wynik —
dzięki temu pobiera z bazy tylko rekordy bieżącej strony.
Dodaj metodę zwracającą zapytanie do src/Repository/JobRepository.php:
use App\Entity\Category;
use Doctrine\ORM\Query;
public function getActiveJobsByCategoryQuery(Category $category): Query
{
return $this->createQueryBuilder('j')
->andWhere('j.category = :category')
->andWhere('j.expiresAt > :now')
->setParameter('category', $category)
->setParameter('now', new \DateTimeImmutable())
->orderBy('j.expiresAt', 'DESC')
->getQuery();
}
Zaktualizuj CategoryController — dodaj drugą trasę ze stroną, zbuduj Pagerfanta z QueryAdapter i
obsłuż wyjście poza zakres stron (404):
<?php
namespace App\Controller;
use App\Entity\Category;
use App\Repository\JobRepository;
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;
final class CategoryController extends AbstractController
{
private const int PER_PAGE = 20;
#[Route('/category/{slug}', name: 'category_show', methods: ['GET'])]
#[Route('/category/{slug}/page/{page}', name: 'category_show_paginated', methods: ['GET'], requirements: ['page' => '\d+'])]
public function show(Category $category, JobRepository $jobs, int $page = 1): Response
{
$pager = new Pagerfanta(new QueryAdapter($jobs->getActiveJobsByCategoryQuery($category)));
$pager->setMaxPerPage(self::PER_PAGE);
try {
$pager->setCurrentPage($page);
} catch (OutOfRangeCurrentPageException) {
throw new NotFoundHttpException();
}
return $this->render('category/show.html.twig', [
'category' => $category,
'pager' => $pager,
]);
}
}
Pagerfanta jest iterowalny — w szablonie przekazujemy go jak zwykłą kolekcję ofert do fragmentu tabeli,
a pod spodem renderujemy nawigację (Bootstrap 5). Zmień templates/category/show.html.twig:
{% extends 'base.html.twig' %}
{% block title %}Oferty w kategorii {{ category.name }}{% endblock %}
{% block body %}
<h1 class="h4 mb-3">{{ category.name }}</h1>
{% include 'job/_table.html.twig' with { jobs: pager } only %}
{% if pager.haveToPaginate %}
<nav class="d-flex justify-content-center mt-4">
<ul class="pagination">
<li class="page-item {{ not pager.hasPreviousPage ? 'disabled' }}">
<a class="page-link" href="{{ pager.hasPreviousPage
? path('category_show_paginated', { slug: category.slug, page: pager.previousPage }) : '#' }}">
« Poprzednia
</a>
</li>
{% for p in range(1, pager.nbPages) %}
<li class="page-item {{ p == pager.currentPage ? 'active' }}">
<a class="page-link" href="{{ path('category_show_paginated', { slug: category.slug, page: p }) }}">{{ p }}</a>
</li>
{% endfor %}
<li class="page-item {{ not pager.hasNextPage ? 'disabled' }}">
<a class="page-link" href="{{ pager.hasNextPage
? path('category_show_paginated', { slug: category.slug, page: pager.nextPage }) : '#' }}">
Następna »
</a>
</li>
</ul>
</nav>
{% endif %}
{% endblock %}
Otwórz kategorię z wieloma ofertami (np. „Programming”) — zobaczysz 20 ofert na stronę i działającą nawigację:

Zmiana vs Symfony 4.2: oryginał używał KnpPaginatorBundle. My stosujemy Pagerfanta (
babdev/pagerfanta-bundle+pagerfanta/doctrine-orm-adapter) — zgodnie ze standardem projektu. MetodyhasPreviousPage,nbPages,currentPageitd. pozwalają wyrenderować nawigację w stylu Bootstrap 5, a wyjście poza zakres stron zwraca 404.
Podsumowanie
Strona kategorii jest gotowa zgodnie z historią F2:
- ✅ pole
slugwCategorygenerowane automatycznie przez cocur/slugify (lifecycle callback), - ✅ czytelne URL-e
/category/{slug}z automatycznym wczytywaniem encji, - ✅ współdzielony fragment
job/_table.html.twig(include +with/only) — bez duplikacji, - ✅ linki z listy kategorii na stronie głównej,
- ✅ paginacja (Pagerfanta +
QueryAdapter), 20 ofert na stronę, nawigacja Bootstrap 5, 404 poza zakresem.
W Dniu 8 zbudujemy formularze — dodawanie, edycję, publikację i usuwanie ofert wraz z walidacją i uploadem logo.