3

Dzień 3 z 18

Model danych

Opublikowany

Przekuwamy specyfikację w model danych. Zdefiniujemy encje Category, Job i Affiliate mapowane atrybutami PHP, ich relacje, automatyczne znaczniki czasu, wygenerujemy migrację i zasilimy bazę PostgreSQL danymi testowymi.

Czego się nauczysz

  • Mapowanie atrybutami PHP: #[ORM\Entity], #[ORM\Column], #[ORM\ManyToOne]
  • Generowanie encji i repozytoriów przez make:entity
  • Encje Category, Job i Affiliate z typowanymi właściwościami
  • Relacje: Job → Category (ManyToOne), Affiliate ↔ Category (ManyToMany)
  • Znaczniki czasu przez lifecycle callbacks i DateTimeImmutable
  • Migracje: make:migration i doctrine:migrations:migrate
  • Dane testowe z DoctrineFixturesBundle 4

Co zmieniło się w Symfony 8 vs 4.2

  • Atrybuty PHP (#[ORM\Entity]) zamiast adnotacji DocBlock
  • make:entity generuje typowane encje, akcesory i repozytorium
  • PostgreSQL 16 zamiast MySQL
  • DateTimeImmutable zamiast DateTime dla znaczników czasu
  • Doctrine ORM 3: getReference() z klasą, brak merge()

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):

Schemat bazy danych Jobeet

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:entity generuje 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 nie ArrayCollection w typie), metody zwracają static, a add*/remove* synchronizują obie strony relacji — tego kodu wygenerowanego przez make:entity w 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 \DateTimeImmutable zamiast \DateTime (obiekty niemutowalne to dziś standard), a relacja to zwięzły atrybut #[ORM\ManyToOne(inversedBy: 'jobs')] — bez targetEntity, 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) oraz doctrine:migrations:migrate (w liczbie mnogiej — migrations). Migracje trafiają do katalogu migrations/.


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\ObjectManager zamiast usuniętego Doctrine\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/:

Zapisz jako sensio-labs.gif

Zapisz jako extreme-sensio.gif


Podsumowanie

Model danych Jobeet jest gotowy:

  • ✅ trzy encje: Category, Job, Affiliate z 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.

Narzędzia / paczki

Doctrine ORM 3 MakerBundle DoctrineFixturesBundle 4 doctrine/migrations

Spis treści