3

Rozdział 3 z 21

Encje bloga — migracja i fixtures

Opublikowany

Projektujemy model danych bloga: artykuły, kategorie, tagi i komentarze. Zdefiniujemy relacje (ManyToOne, ManyToMany, self-referencing dla wątków komentarzy), statusy artykułu, wygenerujemy migrację i zasilimy bazę danymi testowymi.

Czego się nauczysz

  • Encja BlogArticle — pola, statusy, indeksy
  • BlogCategory i relacja ManyToOne
  • BlogTag i relacja ManyToMany (pivot blog_article_tags)
  • BlogComment — wątki (self-referencing parent)
  • Generowanie i uruchamianie migracji
  • Fixtures — dane testowe bloga

Rozdział 3: Encje bloga — migracja i fixtures

Czas na fundament bloga — model danych. Zaprojektujemy cztery encje Doctrine: BlogArticle, BlogCategory, BlogTag i BlogComment, połączymy je relacjami, wygenerujemy migrację i zasilimy bazę danymi testowymi. Po tym rozdziale baza będzie gotowa, a od Rozdziału 4 zaczniemy wyświetlać treść.

Budujemy przyrostowo: w tym rozdziale tworzymy działający rdzeń encji (artykuł z tagami i komentarzami, kategoria, tag, komentarz). Po nim aplikacja się uruchamia, a migracja przechodzi. Kolekcje media (galeria) i translations (tłumaczenia) dołożymy później — w Rozdziałach 10, 14 i 15, razem z encjami, na które wskazują. Dzięki temu po każdym rozdziale masz działający stan, a encja rośnie aż do pełnej wersji docelowej.


Cztery encje i ich relacje

Model bloga opiera się na czterech encjach i czterech relacjach:

BlogCategory ──1:N──> BlogArticle <──N:M──> BlogTag
                          │
                          │ 1:N
                          ▼
                     BlogComment ──self──> BlogComment (odpowiedzi)
  • Artykuł należy do jednej kategorii (ManyToOne) i ma wiele tagów (ManyToMany).
  • Artykuł ma wiele komentarzy (OneToMany).
  • Komentarz może mieć rodzica (parent) — to samo-odwołanie (self-referencing) daje wątki (odpowiedzi na odpowiedzi).

Encje generujemy interaktywnie komendą make:entity, a potem dopieszczamy atrybuty ręcznie:

docker compose exec app php bin/console make:entity BlogCategory

Poniżej pokazujemy docelowy kształt mapowania (gettery/settery generuje make:entity — pomijamy je dla zwięzłości).


Encja BlogArticle

Serce bloga. Trzyma tytuł, slug (do URL-a), zajawkę, treść w Markdown, status i licznik odsłon:

// src/Entity/BlogArticle.php
#[ORM\Entity(repositoryClass: BlogArticleRepository::class)]
#[ORM\Table(name: 'blog_articles')]
#[ORM\Index(fields: ['status'], name: 'idx_blog_articles_status')]
#[ORM\Index(fields: ['publishedAt'], name: 'idx_blog_articles_published_at')]
#[ORM\Index(fields: ['author'], name: 'idx_blog_articles_author_id')]
#[ORM\Index(fields: ['category'], name: 'idx_blog_articles_category_id')]
class BlogArticle
{
    public const STATUS_DRAFT = 'draft';
    public const STATUS_PUBLISHED = 'published';
    public const STATUS_ARCHIVED = 'archived';
    public const STATUS_HIDDEN = 'hidden';

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $author = null;

    #[ORM\ManyToOne(targetEntity: BlogCategory::class, inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: true)]
    private ?BlogCategory $category = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: 'Tytuł artykułu nie może być pusty.')]
    #[Assert\Length(max: 255)]
    private ?string $title = null;

    #[ORM\Column(length: 255, unique: true)]
    private ?string $slug = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $excerpt = null;

    #[ORM\Column(type: 'text')]
    private ?string $content = null;

    #[ORM\Column(length: 20)]
    private string $status = self::STATUS_DRAFT;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $publishedAt = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $updatedAt = null;

    #[ORM\Column(options: ['default' => 0])]
    private int $viewsCount = 0;

    /** @var Collection<int, BlogTag> */
    #[ORM\ManyToMany(targetEntity: BlogTag::class, inversedBy: 'articles')]
    #[ORM\JoinTable(
        name: 'blog_article_tags',
        joinColumns: [new ORM\JoinColumn(name: 'article_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
        inverseJoinColumns: [new ORM\JoinColumn(name: 'tag_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
    )]
    private Collection $tags;

    /** @var Collection<int, BlogComment> */
    #[ORM\OneToMany(targetEntity: BlogComment::class, mappedBy: 'article', cascade: ['remove'])]
    private Collection $comments;

    public function __construct()
    {
        $this->tags = new ArrayCollection();
        $this->comments = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }
}

Warto zauważyć:

  • Statusy jako stałe (STATUS_*) — unikamy „magicznych stringów” w kodzie i szablonach.
  • slug jest unique — to on trafia do URL-a /blog/{slug} (Rozdział 5).
  • content to text — trzymamy Markdown; render na HTML robimy przy wyświetlaniu (Rozdział 9).
  • Cztery indeksy (status, publishedAt, author, category) — po tych polach filtrujemy i łączymy listę (Rozdziały 4 i 6).
  • JoinTable z onDelete: CASCADE — usunięcie artykułu lub tagu czyści wpisy w tabeli pośredniej blog_article_tags na poziomie bazy.
  • Encja urośnie w kolejnych rozdziałach: kolekcję media (galeria) dodamy w Rozdziale 10, a translations (tłumaczenia) w Rozdziale 14 — razem z encjami, na które wskazują. Teraz zostajemy przy rdzeniu, żeby aplikacja od razu działała.
  • Kolekcje tags i comments inicjalizujemy w konstruktorze — ArrayCollection, inaczej dostaniesz błąd przy ->add(...).

Kategorie i tagi

Kategoria grupuje artykuły (jeden artykuł → jedna kategoria). Relacja OneToMany po stronie kategorii:

// src/Entity/BlogCategory.php
#[ORM\Entity(repositoryClass: BlogCategoryRepository::class)]
#[ORM\Table(name: 'blog_categories')]
class BlogCategory
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank]
    private ?string $name = null;

    #[ORM\Column(length: 120, unique: true)]
    private ?string $slug = null;

    /** @var Collection<int, BlogArticle> */
    #[ORM\OneToMany(targetEntity: BlogArticle::class, mappedBy: 'category')]
    private Collection $articles;

    public function __construct()
    {
        $this->articles = new ArrayCollection();
    }
}

Kategoria dostanie własną kolekcję translations (tłumaczenia nazw) w Rozdziale 15 — teraz zostaje przy nazwie i slug-u.

Tag wiąże się z artykułami relacją ManyToMany. Uwaga na strony relacji: BlogArticle jest stroną właścicielską (ma #[ORM\JoinTable]), a BlogTag — odwrotną (mappedBy: 'tags'):

// src/Entity/BlogTag.php
#[ORM\Entity(repositoryClass: BlogTagRepository::class)]
#[ORM\Table(name: 'blog_tags')]
class BlogTag
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 50, unique: true)]
    private ?string $name = null;

    #[ORM\Column(length: 60, unique: true)]
    private ?string $slug = null;

    /** @var Collection<int, BlogArticle> */
    #[ORM\ManyToMany(targetEntity: BlogArticle::class, mappedBy: 'tags')]
    private Collection $articles;

    public function __construct()
    {
        $this->articles = new ArrayCollection();
    }
}

Doctrine sam utworzy tabelę pośrednią blog_article_tags (kolumny article_id, tag_id) — nie tworzymy dla niej osobnej encji.

Strona właścicielska vs odwrotna: przy ManyToMany tylko jedna strona zapisuje relację do bazy — ta z JoinTable (tu BlogArticle). Dodając tag, rób to od strony artykułu: $article->addTag($tag).


Komentarze z wątkami

Komentarz należy do artykułu i do użytkownika. Wątki (odpowiedzi) realizujemy self-referencing — komentarz wskazuje na parent (rodzica) i ma kolekcję replies (odpowiedzi):

// src/Entity/BlogComment.php
#[ORM\Entity(repositoryClass: BlogCommentRepository::class)]
#[ORM\Table(name: 'blog_comments')]
#[ORM\Index(fields: ['article'], name: 'idx_blog_comments_article_id')]
#[ORM\Index(fields: ['user'], name: 'idx_blog_comments_user_id')]
#[ORM\Index(fields: ['parent'], name: 'idx_blog_comments_parent_id')]
class BlogComment
{
    public const STATUS_PUBLISHED = 'published';
    public const STATUS_PENDING = 'pending';
    public const STATUS_SPAM = 'spam';

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: BlogArticle::class, inversedBy: 'comments')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?BlogArticle $article = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $user = null;

    #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'replies')]
    #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
    private ?self $parent = null;

    /** @var Collection<int, BlogComment> */
    #[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent', cascade: ['remove'])]
    private Collection $replies;

    #[ORM\Column(type: 'text')]
    private ?string $content = null;

    #[ORM\Column(length: 20)]
    private string $status = self::STATUS_PUBLISHED;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $updatedAt = null;

    public function __construct()
    {
        $this->replies = new ArrayCollection();
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }
}

Kluczowe:

  • onDelete: 'CASCADE' na article i parent — usunięcie artykułu lub komentarza-rodzica kasuje powiązane komentarze na poziomie bazy danych (bez ręcznego sprzątania).
  • Statusy komentarza (published/pending/spam) przydadzą się przy moderacji (Rozdział 7).
  • Threading pełną obsługę dostanie w Rozdziale 6 — tu zakładamy tylko strukturę.

Migracja

Gdy encje są gotowe, generujemy migrację (Doctrine porówna encje ze stanem bazy i wygeneruje SQL):

docker compose exec app php bin/console make:migration

Powstanie plik w migrations/ z instrukcjami CREATE TABLE blog_articles, blog_categories, blog_tags, blog_article_tags, blog_comments wraz z indeksami i kluczami obcymi. Zajrzyj do niego przed uruchomieniem — nigdy nie stosujemy migracji „w ciemno”. Następnie:

Później: dodatkowe tabele (blog_media, blog_article_translation, blog_category_translation) powstaną w migracjach swoich rozdziałów (10, 14, 15). Teraz migrujemy tylko rdzeń — i to wystarcza, by blog działał.

docker compose exec app php bin/console doctrine:migrations:migrate

Sprawdź strukturę w Adminerze (http://localhost:8091) albo w psql:

docker compose exec database psql -U app -d app -c '\dt blog_*'

Fixtures — dane testowe

Pusta baza to słaby start. Fixtures (DoctrineFixturesBundle) generują powtarzalne dane testowe. BlogFixtures zależy od UserFixtures (artykuł potrzebuje autora), więc implementujemy DependentFixtureInterface:

// src/DataFixtures/BlogFixtures.php
namespace App\DataFixtures;

use App\Entity\BlogArticle;
use App\Entity\BlogCategory;
use App\Entity\BlogTag;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;

class BlogFixtures extends Fixture implements DependentFixtureInterface
{
    public function getDependencies(): array
    {
        return [UserFixtures::class];
    }

    public function load(ObjectManager $manager): void
    {
        $admin = $this->getReference('user_admin@example.com', User::class);

        // Kategorie
        $programowanie = new BlogCategory();
        $programowanie->setName('Programowanie')->setSlug('programowanie');
        $manager->persist($programowanie);

        // Tagi
        $php = new BlogTag();
        $php->setName('PHP')->setSlug('php');
        $manager->persist($php);

        // Artykuł
        $article = new BlogArticle();
        $article->setAuthor($admin)
            ->setCategory($programowanie)
            ->setTitle('Pierwszy wpis na blogu')
            ->setSlug('pierwszy-wpis-na-blogu')
            ->setExcerpt('Krótka zajawka pierwszego artykułu.')
            ->setContent("## Cześć\n\nTo jest treść w **Markdown**.")
            ->setStatus(BlogArticle::STATUS_PUBLISHED)
            ->setPublishedAt(new \DateTimeImmutable('-1 day'))
            ->addTag($php);
        $manager->persist($article);

        $manager->flush();
    }
}

Załaduj dane (uwaga: --purge-with-truncate czyści tabele przed wczytaniem):

docker compose exec app php bin/console doctrine:fixtures:load

getReference(): UserFixtures rejestruje użytkowników przez addReference('user_...', $user). Dzięki temu w BlogFixtures pobieramy istniejącego admina bez tworzenia go od nowa — i artykuł ma autora.


Podsumowanie

Model danych bloga stoi:

  • ✅ cztery encje (rdzeń): BlogArticle, BlogCategory, BlogTag, BlogComment,
  • ✅ relacje: kategoria 1:N artykuł, artykuł N:M tag (pivot blog_article_tags z CASCADE), artykuł 1:N komentarz, komentarz self-referencing (wątki),
  • ✅ statusy jako stałe, slug unikalny, cztery indeksy na BlogArticle, kolekcje tags/comments w konstruktorze,
  • ✅ migracja (make:migration → migrate) i fixtures z getReference() do autora,
  • ✅ działający stan: aplikacja się uruchamia, tabele istnieją, dane testowe wczytane (encja urośnie o media i tłumaczenia w Rozdziałach 10, 14 i 15).

W Rozdziale 4 wyświetlimy listę artykułów z paginacją — BlogController, zapytanie opublikowanych artykułów zwracające Query i paginacja przez Pagerfanta.

Narzędzia / paczki

Doctrine ORM 3 doctrine/migrations DoctrineFixturesBundle

Spis treści