Dzień 3: Model danych
Mamy już specyfikację (Dzień 2), więc czas przekuć ją w model danych. Zdefiniujemy encje Doctrine
(Category, Job, Affiliate), relacje między nimi, wygenerujemy pierwszą migrację i zasilimy bazę
danymi testowymi (fixtures). Po tym rozdziale baza PostgreSQL będzie miała komplet tabel z przykładowymi
ofertami.
W Symfony 8 mapowanie opisujemy atrybutami PHP (
#[ORM\Entity]), a encje generujemy komendąmake:entity, która tworzy typowane właściwości, gettery/settery i klasę repozytorium.
Model relacyjny
Historie użytkownika z Dnia 2 opisują główne obiekty projektu: oferty (jobs), kategorie (categories) i partnerów (affiliates). Odpowiadający im diagram encji (ERD):

Oprócz kolumn wynikających z historii dodaliśmy created_at i updated_at. Skonfigurujemy Doctrine tak,
aby ustawiał je automatycznie przy zapisie i aktualizacji obiektu (lifecycle callbacks).
Baza danych i połączenie
W Dniu 1 pakiet --webapp skonfigurował już Doctrine i połączenie z PostgreSQL 16. Parametry są w
pliku .env (i nadpisane na sieć Dockera w compose.yaml):
# .env — wartość domyślna
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
Zmiana vs Symfony 4.2: oryginał używał MySQL (
mysql://…:3306). My korzystamy z PostgreSQL 16, zgodnie ze stackiem projektu.
Utwórz bazę (jeśli jeszcze nie istnieje):
docker compose exec app php bin/console doctrine:database:create --if-not-exists
Generowanie encji przez make:entity
Zamiast pisać klasy ręcznie, użyjemy interaktywnego generatora z MakerBundle. Tworzy on typowaną
klasę encji, klasę repozytorium (src/Repository/…Repository.php) oraz gettery/settery.
docker compose exec app php bin/console make:entity Category
docker compose exec app php bin/console make:entity Job
docker compose exec app php bin/console make:entity Affiliate
Kreator pyta o kolejne pola (nazwa, typ, długość, czy może być null). Relacje (ManyToOne,
OneToMany, ManyToMany) też dodasz przez make:entity — wystarczy podać typ relation.
Zmiana vs Symfony 4.2: oryginał ręcznie pisał właściwości
private $id;i mapowanie w adnotacjach DocBlock (/** @ORM\Column */), a gettery/settery generował w IDE. Dziśmake:entitygeneruje typowane właściwości i atrybuty PHP (#[ORM\Column]), a także repozytorium.
Poniżej pokazujemy docelowy kształt encji. Możesz je wpisać przez make:entity albo dostosować
wygenerowane pliki.
Encja Category
Najmniejsza encja — pokazujemy ją w całości (wraz z akcesorami), jako wzorzec tego, co generuje
make:entity:
<?php
namespace App\Entity;
use App\Repository\CategoryRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
#[ORM\Table(name: 'categories')]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private ?string $name = null;
/** @var Collection<int, Job> */
#[ORM\OneToMany(mappedBy: 'category', targetEntity: Job::class)]
private Collection $jobs;
/** @var Collection<int, Affiliate> */
#[ORM\ManyToMany(targetEntity: Affiliate::class, mappedBy: 'categories')]
private Collection $affiliates;
public function __construct()
{
$this->jobs = new ArrayCollection();
$this->affiliates = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
/** @return Collection<int, Job> */
public function getJobs(): Collection
{
return $this->jobs;
}
public function addJob(Job $job): static
{
if (!$this->jobs->contains($job)) {
$this->jobs->add($job);
$job->setCategory($this);
}
return $this;
}
public function removeJob(Job $job): static
{
// ustaw drugą stronę relacji na null (jeśli to konieczne)
if ($this->jobs->removeElement($job) && $job->getCategory() === $this) {
$job->setCategory(null);
}
return $this;
}
/** @return Collection<int, Affiliate> */
public function getAffiliates(): Collection
{
return $this->affiliates;
}
public function addAffiliate(Affiliate $affiliate): static
{
if (!$this->affiliates->contains($affiliate)) {
$this->affiliates->add($affiliate);
$affiliate->addCategory($this);
}
return $this;
}
public function removeAffiliate(Affiliate $affiliate): static
{
if ($this->affiliates->removeElement($affiliate)) {
$affiliate->removeCategory($this);
}
return $this;
}
}
Zmiana vs Symfony 4.2: kolekcje mają typ
Collection(a nieArrayCollectionw typie), metody zwracająstatic, aadd*/remove*synchronizują obie strony relacji — tego kodu wygenerowanego przezmake:entityw oryginale nie było.
Encja Job
Najważniejsza encja. Pełne mapowanie właściwości, relacja do Category, konstruktor niepotrzebny
(brak kolekcji). Gettery/settery generuje make:entity — poniżej pokazujemy właściwości, atrybuty i
relację; komplet akcesorów znajdziesz w wygenerowanym pliku.
<?php
namespace App\Entity;
use App\Repository\JobRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: JobRepository::class)]
#[ORM\Table(name: 'jobs')]
#[ORM\HasLifecycleCallbacks]
class Job
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $type = null;
#[ORM\Column(length: 255)]
private ?string $company = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $logo = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $url = null;
#[ORM\Column(length: 255)]
private ?string $position = null;
#[ORM\Column(length: 255)]
private ?string $location = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $description = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $howToApply = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $token = null;
#[ORM\Column]
private ?bool $public = null;
#[ORM\Column]
private ?bool $activated = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column]
private ?\DateTimeImmutable $expiresAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(inversedBy: 'jobs')]
#[ORM\JoinColumn(name: 'category_id', nullable: false)]
private ?Category $category = null;
// --- Gettery i settery wygenerowane przez make:entity (get*/set*, is* dla boolów) ---
// np. getId(), getType()/setType(), isPublic()/setPublic(), getCategory()/setCategory() itd.
}
Typy kolumn wynikają wprost z typu właściwości — Doctrine zmapuje \DateTimeImmutable na
datetime_immutable, a bool na boolean. Dłuższe teksty oznaczamy type: Types::TEXT.
Zmiana vs Symfony 4.2: używamy
\DateTimeImmutablezamiast\DateTime(obiekty niemutowalne to dziś standard), a relacja to zwięzły atrybut#[ORM\ManyToOne(inversedBy: 'jobs')]— beztargetEntity, który Doctrine wywnioskuje z typu właściwości.
Encja Affiliate
<?php
namespace App\Entity;
use App\Repository\AffiliateRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AffiliateRepository::class)]
#[ORM\Table(name: 'affiliates')]
#[ORM\HasLifecycleCallbacks]
class Affiliate
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $url = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $token = null;
#[ORM\Column]
private ?bool $active = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
/** @var Collection<int, Category> */
#[ORM\ManyToMany(targetEntity: Category::class, inversedBy: 'affiliates')]
#[ORM\JoinTable(name: 'affiliates_categories')]
private Collection $categories;
public function __construct()
{
$this->categories = new ArrayCollection();
}
// --- Gettery/settery + addCategory()/removeCategory() wygenerowane przez make:entity ---
}
Znaczniki czasu — lifecycle callbacks
Chcemy, aby created_at i updated_at ustawiały się same. Służą do tego lifecycle callbacks —
metody wywoływane przez Doctrine w konkretnych momentach cyklu życia encji. Klasę oznaczamy
#[ORM\HasLifecycleCallbacks] (już dodane wyżej), a metody — #[ORM\PrePersist] i #[ORM\PreUpdate].
W encji Job dodaj:
#[ORM\PrePersist]
public function setTimestampsOnCreate(): void
{
$now = new \DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function setTimestampOnUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
W encji Affiliate (tylko created_at):
#[ORM\PrePersist]
public function setCreatedAtOnCreate(): void
{
$this->createdAt = new \DateTimeImmutable();
}
Zmiana vs Symfony 4.2: callbacki opisujemy atrybutami
#[ORM\PrePersist]/#[ORM\PreUpdate]zamiast adnotacji, a znaczniki czasu to\DateTimeImmutable.
Walidacja mapowania
Po zdefiniowaniu encji sprawdź poprawność mapowania:
docker compose exec app php bin/console doctrine:schema:validate
Powinieneś zobaczyć:
Mapping
-------
[OK] The mapping files are correct.
Database
--------
[ERROR] The database schema is not in sync with the current mapping file.
Błąd bazy jest oczekiwany — tabel jeszcze nie ma. Naprawimy to migracją za chwilę.
Migracje — tworzenie schematu bazy
Doctrine potrafi sam wygenerować migrację z SQL synchronizującym bazę z encjami. Wygeneruj pierwszą migrację, a potem ją uruchom:
docker compose exec app php bin/console make:migration
docker compose exec app php bin/console doctrine:migrations:migrate --no-interaction
Po migracji baza ma wszystkie tabele opisane w encjach.
Zmiana vs Symfony 4.2: komenda to
make:migration(alias MakerBundle) orazdoctrine:migrations:migrate(w liczbie mnogiej —migrations). Migracje trafiają do katalogumigrations/.
Dane początkowe — fixtures
Tabele są puste. Aby zasilić bazę danymi, użyjemy DoctrineFixturesBundle (w projekcie --webapp
jest już zainstalowany; jeśli nie: composer require --dev orm-fixtures).
Najpierw kategorie — src/DataFixtures/CategoryFixtures.php:
<?php
namespace App\DataFixtures;
use App\Entity\Category;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class CategoryFixtures extends Fixture
{
public const DESIGN = 'category-design';
public const PROGRAMMING = 'category-programming';
public const MANAGER = 'category-manager';
public const ADMINISTRATOR = 'category-administrator';
public function load(ObjectManager $manager): void
{
$names = [
self::DESIGN => 'Design',
self::PROGRAMMING => 'Programming',
self::MANAGER => 'Manager',
self::ADMINISTRATOR => 'Administrator',
];
foreach ($names as $reference => $name) {
$category = (new Category())->setName($name);
$manager->persist($category);
$this->addReference($reference, $category);
}
$manager->flush();
}
}
Teraz oferty — src/DataFixtures/JobFixtures.php:
<?php
namespace App\DataFixtures;
use App\Entity\Job;
use App\Entity\Category;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class JobFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
$sensioLabs = (new Job())
->setCategory($this->getReference(CategoryFixtures::PROGRAMMING, Category::class))
->setType('full-time')
->setCompany('Sensio Labs')
->setLogo('sensio-labs.gif')
->setUrl('https://sensiolabs.com/')
->setPosition('Web Developer')
->setLocation('Paris, France')
->setDescription('Masz doświadczenie w Symfony i chcesz pracować z technologiami Open Source. Minimum 3 lata doświadczenia w PHP.')
->setHowToApply('Wyślij CV na fabien.potencier [at] sensio.com')
->setPublic(true)
->setActivated(true)
->setToken('job_sensio_labs')
->setEmail('job@example.com')
->setExpiresAt(new \DateTimeImmutable('+30 days'));
$extremeSensio = (new Job())
->setCategory($this->getReference(CategoryFixtures::DESIGN, Category::class))
->setType('part-time')
->setCompany('Extreme Sensio')
->setLogo('extreme-sensio.gif')
->setUrl('https://extreme-sensio.com/')
->setPosition('Web Designer')
->setLocation('Paris, France')
->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt.')
->setHowToApply('Wyślij CV na fabien.potencier [at] sensio.com')
->setPublic(true)
->setActivated(true)
->setToken('job_extreme_sensio')
->setEmail('job@example.com')
->setExpiresAt(new \DateTimeImmutable('+30 days'));
$manager->persist($sensioLabs);
$manager->persist($extremeSensio);
$manager->flush();
}
public function getDependencies(): array
{
return [CategoryFixtures::class];
}
}
JobFixtures implementuje DependentFixtureInterface — metoda getDependencies() gwarantuje, że
CategoryFixtures wykona się wcześniej (oferty potrzebują kategorii).
Zmiana vs Symfony 4.2: trzy istotne różnice:
Doctrine\Persistence\ObjectManagerzamiast usuniętegoDoctrine\Common\Persistence\ObjectManager,getReference()wymaga drugiego argumentu z klasą encji (Category::class),- brak
$manager->merge(...)— ta metoda została usunięta w Doctrine ORM 3.
Załaduj dane:
docker compose exec app php bin/console doctrine:fixtures:load --no-interaction
Sprawdź bazę — tabele categories i jobs powinny być wypełnione.
Loga ofert
Fixtures odwołują się do dwóch plików. Zapisz je w katalogu public/uploads/jobs/:


Podsumowanie
Model danych Jobeet jest gotowy:
- ✅ trzy encje:
Category,Job,Affiliatez mapowaniem przez atrybuty PHP, - ✅ relacje:
Job → Category(ManyToOne),Affiliate ↔ Category(ManyToMany), - ✅ automatyczne znaczniki czasu przez lifecycle callbacks i
\DateTimeImmutable, - ✅ schemat bazy utworzony migracją,
- ✅ dane testowe z fixtures (kategorie + oferty) i loga firm.
W Dniu 4 zbudujemy pierwsze strony: kontroler JobController, listę ofert i stronę szczegółową —
poznamy wzorzec MVC, atrybut #[Route] i szablony Twig 3.