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) itranslations(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. slugjestunique— to on trafia do URL-a/blog/{slug}(Rozdział 5).contenttotext— 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). JoinTablezonDelete: CASCADE— usunięcie artykułu lub tagu czyści wpisy w tabeli pośredniejblog_article_tagsna poziomie bazy.- Encja urośnie w kolejnych rozdziałach: kolekcję
media(galeria) dodamy w Rozdziale 10, atranslations(tłumaczenia) w Rozdziale 14 — razem z encjami, na które wskazują. Teraz zostajemy przy rdzeniu, żeby aplikacja od razu działała. - Kolekcje
tagsicommentsinicjalizujemy 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(tuBlogArticle). 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'naarticleiparent— 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():UserFixturesrejestruje użytkowników przezaddReference('user_...', $user). Dzięki temu wBlogFixturespobieramy 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_tagszCASCADE), artykuł 1:N komentarz, komentarz self-referencing (wątki), - ✅ statusy jako stałe,
slugunikalny, cztery indeksy naBlogArticle, kolekcjetags/commentsw konstruktorze, - ✅ migracja (
make:migration→migrate) i fixtures zgetReference()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.