Dzień 6: Więcej z modelem
Strona główna pokazuje wszystkie oferty — także nieaktywne. Zgodnie z historią F1 z Dnia 2 powinna pokazywać tylko aktywne oferty, pogrupowane według kategorii, maksymalnie 10 na kategorię. Dziś przeniesiemy logikę zapytań do repozytoriów, poznamy QueryBuilder, ustawimy automatyczne wygaśnięcie oferty i zabezpieczymy stronę przeterminowanej oferty.
Aktywna oferta to taka, której
expiresAtjest w przyszłości. Repozytoria (JobRepository,CategoryRepository) wygenerował jużmake:entityw Dniu 3 — dziś dodajemy do nich metody.
Logika zapytań należy do repozytorium
W Dniu 4 akcja list() pobierała wszystkie oferty przez findAll(). Zapytania to jednak logika
warstwy Modelu, a nie kontrolera. Zgodnie z MVC kontroler ma tylko wołać Model i przekazywać dane do
widoku. Dlatego zapytania umieszczamy w repozytorium.
Repozytorium to wzorzec — warstwa abstrakcji nad encją, zawierająca metody do pobierania danych z
bazy. W Symfony 8 repozytoria dziedziczą po ServiceEntityRepository i można je wstrzykiwać do
kontrolerów i serwisów.
Zmiana vs Symfony 4.2: w oryginale trzeba było ręcznie tworzyć klasę repozytorium (rozszerzającą
EntityRepository) i wskazywać ją w adnotacji encji. Dziśmake:entitygeneruje repozytorium rozszerzająceServiceEntityRepositoryi od razu podpina je do encji (repositoryClass:), a my tylko dopisujemy metody.
QueryBuilder — aktywne oferty
Dodaj metodę findActiveJobs() do istniejącego src/Repository/JobRepository.php (konstruktor i klasę
wygenerował make:entity):
<?php
namespace App\Repository;
use App\Entity\Job;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class JobRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Job::class);
}
/** @return Job[] */
public function findActiveJobs(?int $categoryId = null): array
{
$qb = $this->createQueryBuilder('j')
->andWhere('j.expiresAt > :now')
->setParameter('now', new \DateTimeImmutable())
->orderBy('j.expiresAt', 'DESC');
if (null !== $categoryId) {
$qb->andWhere('j.category = :categoryId')
->setParameter('categoryId', $categoryId);
}
return $qb->getQuery()->getResult();
}
}
createQueryBuilder('j') buduje zapytanie DQL programistycznie — czytelnie i bezpiecznie (parametry
:now chronią przed SQL injection). Metoda przyjmuje opcjonalny categoryId, co pozwoli użyć jej też na
stronie kategorii.
Zmiana vs Symfony 4.2: w oryginale używano DQL ze skróconą składnią
SELECT j FROM App:Job jw kontrolerze. SkrótApp:Jobzostał usunięty w Doctrine ORM 3 — dziś używamy QueryBuilera (albo pełnej nazwyApp\Entity\Job). Zwróć uwagę, że akcjalist()z Dnia 4 już wstrzykuje repozytorium — metoda$this->getDoctrine()została usunięta w Symfony 6.
Automatyczne wygaśnięcie oferty
Aby oferta wygasała 30 dni po utworzeniu (o ile nie ustawiono expiresAt ręcznie), rozszerzamy lifecycle
callback #[ORM\PrePersist] w src/Entity/Job.php:
#[ORM\PrePersist]
public function setTimestampsOnCreate(): void
{
$now = new \DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->expiresAt) {
$this->expiresAt = $now->modify('+30 days');
}
}
Ponieważ \DateTimeImmutable jest niemutowalny, modify() zwraca nowy obiekt — przypisujemy go do
expiresAt. Nie potrzebujemy już clone, którego wymagał mutowalny \DateTime w oryginale.
Podgląd zapytań SQL
Warto widzieć SQL generowany przez Doctrine — np. przy diagnozie wolnego zapytania. W środowisku dev
służy do tego Web Debug Toolbar: ikona Doctrine na dolnym pasku pokazuje liczbę zapytań, a po
kliknięciu — pełny SQL, parametry i czas wykonania każdego z nich (http://localhost:8090/_profiler,
zakładka Doctrine).
Kategorie na stronie głównej
Strona główna ma grupować oferty według kategorii — i pokazywać tylko kategorie z co najmniej jedną
aktywną ofertą. Dodaj metodę do src/Repository/CategoryRepository.php:
/** @return Category[] */
public function findWithActiveJobs(): array
{
return $this->createQueryBuilder('c')
->innerJoin('c.jobs', 'j')
->andWhere('j.expiresAt > :now')
->setParameter('now', new \DateTimeImmutable())
->distinct()
->getQuery()
->getResult();
}
innerJoin łączy kategorie z ofertami, a distinct() zapobiega duplikatom kategorii (gdy kategoria ma
wiele aktywnych ofert).
Metoda zwraca kategorie, ale wywołanie category.jobs w szablonie dałoby wszystkie oferty, także
przeterminowane. Dodajmy więc do encji Category metodę filtrującą tylko aktywne:
use Doctrine\Common\Collections\Collection;
/** @return Collection<int, Job> */
public function getActiveJobs(): Collection
{
$now = new \DateTimeImmutable();
return $this->jobs->filter(fn (Job $job): bool => $job->getExpiresAt() > $now);
}
Aktualizacja kontrolera i szablonu
Zmień akcję list() w JobController, aby pobierała kategorie z aktywnymi ofertami:
use App\Repository\CategoryRepository;
#[Route('/', name: 'job_list', methods: ['GET'])]
public function list(CategoryRepository $categories): Response
{
return $this->render('job/list.html.twig', [
'categories' => $categories->findWithActiveJobs(),
]);
}
Zaktualizuj templates/job/list.html.twig, aby iterować po kategoriach i wyświetlać ich aktywne oferty:
{% extends 'base.html.twig' %}
{% block title %}Jobeet — oferty pracy{% endblock %}
{% block body %}
{% for category in categories %}
<h2 class="h4 mt-4">{{ category.name }}</h2>
<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 category.activeJobs %}
<tr>
<td>{{ job.location }}</td>
<td><a href="{{ path('job_show', { id: job.id }) }}">{{ job.position }}</a></td>
<td>{{ job.company }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% endblock %}
W Twigu category.activeJobs wywołuje metodę getActiveJobs() z encji.
Limit ofert na kategorię
Historia F1 wymaga maksymalnie 10 ofert na kategorię. Najprościej ograniczyć to filtrem slice:
{% for job in category.activeJobs|slice(0, 10) %}
Konfigurowalny limit
Zaszyta na sztywno „10” to zły pomysł. Zdefiniujmy parametr w config/services.yaml:
parameters:
max_jobs_on_homepage: 10
Aby był dostępny w szablonach, udostępnijmy go jako zmienną globalną Twiga w config/packages/twig.yaml:
twig:
globals:
max_jobs_on_homepage: '%max_jobs_on_homepage%'
Teraz w szablonie:
{% for job in category.activeJobs|slice(0, max_jobs_on_homepage) %}
Dynamiczne fixtures
Przy dwóch ofertach nie zobaczymy działania limitu. Wygenerujmy więcej ofert w pętli oraz jedną
przeterminowaną — w src/DataFixtures/JobFixtures.php, w metodzie load():
// oferta przeterminowana (nie powinna pojawić się na stronie głównej)
$expired = (new Job())
->setCategory($this->getReference(CategoryFixtures::PROGRAMMING, Category::class))
->setType('full-time')
->setCompany('Sensio Labs')
->setPosition('Web Developer (expired)')
->setLocation('Paris, France')
->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.')
->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit')
->setPublic(true)
->setActivated(true)
->setToken('job_expired')
->setEmail('job@example.com')
->setExpiresAt(new \DateTimeImmutable('-10 days'));
$manager->persist($expired);
// 30 aktywnych ofert (expiresAt ustawi automatycznie prePersist)
for ($i = 1; $i <= 30; $i++) {
$job = (new Job())
->setCategory($this->getReference(CategoryFixtures::PROGRAMMING, Category::class))
->setType('full-time')
->setCompany(sprintf('Company %d', $i))
->setPosition('Web Developer')
->setLocation('Paris, France')
->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.')
->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit')
->setPublic(true)
->setActivated(true)
->setToken(sprintf('job_%d', $i))
->setEmail('job@example.com');
$manager->persist($job);
}
$manager->flush();
Przeładuj dane i sprawdź stronę główną — kategoria „Programming” pokaże maksymalnie 10 ofert, a oferta przeterminowana się nie pojawi:
docker compose exec app php bin/console doctrine:fixtures:load --no-interaction
Zmiana vs Symfony 4.2: referencje pobieramy przez
getReference(ref, Category::class)(drugi argument z klasą), bez usuniętego$manager->merge(...). Daty to\DateTimeImmutable.
Zabezpieczenie strony oferty
Gdy oferta wygaśnie, nie powinno dać się jej otworzyć nawet znając URL. Dodaj metodę findActiveJob() do
JobRepository:
public function findActiveJob(int $id): ?Job
{
return $this->createQueryBuilder('j')
->andWhere('j.id = :id')
->andWhere('j.expiresAt > :now')
->setParameter('id', $id)
->setParameter('now', new \DateTimeImmutable())
->getQuery()
->getOneOrNullResult();
}
Następnie w JobController powiedz resolverowi encji, aby użył tej metody — przez atrybut
#[MapEntity] z wyrażeniem:
use App\Entity\Job;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
#[Route('/job/{id}', name: 'job_show', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(
#[MapEntity(expr: 'repository.findActiveJob(id)')] Job $job,
): Response {
return $this->render('job/show.html.twig', [
'job' => $job,
]);
}
Gdy findActiveJob() zwróci null (oferta wygasła lub nie istnieje), Symfony automatycznie pokaże
stronę 404.
Zmiana vs Symfony 4.2: w oryginale służyła do tego adnotacja
@Entity("job", expr="…")zSensioFrameworkExtraBundle. Dziś używamy natywnego atrybutu#[MapEntity(expr: '…')]zsymfony/doctrine-bridge— bez zewnętrznego bundla.
Podsumowanie
Strona główna działa zgodnie ze specyfikacją:
- ✅ logika zapytań przeniesiona do repozytoriów (
ServiceEntityRepository), - ✅ QueryBuilder zamiast DQL ze skrótem
App:Job(usuniętym w ORM 3), - ✅ automatyczne
expiresAt(+30 dni) w lifecycle callback, - ✅ oferty pogrupowane po kategoriach, tylko aktywne, limit z konfigurowalnego parametru,
- ✅ dynamiczne fixtures (pętla) z automatycznym wygaśnięciem,
- ✅ zabezpieczenie strony oferty przez
#[MapEntity(expr: …)](404 dla wygasłych).
W Dniu 7 zbudujemy pełną stronę kategorii z listą wszystkich ofert, paginacją i rozwiążemy problem N+1 zapytań.