Démonstration ORM

Lemonade Blog

Un petit blog propulsé par Doctrine ORM et SQLite.

Doctrine ORM · SQLite · ManyToMany

Résultat ORM en direct

Derniers articles

1234 articles

Cache keys with useful namespaces — field note 1225

Namespaced keys make invalidation easier to understand. This deterministic field note expands the demo dataset.

Deployment checks before traffic — field note 1226

Verify configuration, storage, and workers before release. This deterministic field note expands the demo dataset.

A small event listener — field note 1227

Listeners can react to domain facts without controller coupling. This deterministic field note expands the demo dataset.

Database indexes for real queries — field note 1228

Add indexes for the access patterns the application performs. This deterministic field note expands the demo dataset.

Forms with stable error paths — field note 1229

Keep labels and errors near the fields they describe. This deterministic field note expands the demo dataset.

Testing command output — field note 1230

CLI tests protect the messages operators rely on. This deterministic field note expands the demo dataset.

HTTP responses with explicit intent — field note 1231

Status codes and headers are part of the application contract. This deterministic field note expands the demo dataset.

Middleware order is part of behavior — field note 1232

Document ordering when one policy depends on another. This deterministic field note expands the demo dataset.

A queue payload that stays small — field note 1233

Pass identifiers instead of serializing a whole object graph. This deterministic field note expands the demo dataset.

Configuration for different environments — field note 1234

Use explicit environment values rather than hidden branches. This deterministic field note expands the demo dataset.

Comment fonctionne ce blog

L’intégration de Doctrine ORM dans cet exemple

Le flux du blog paginé lit les ID de la page avec toIterable(); un lot graphique borné charge ensuite les auteurs et les tags sans requêtes N+1.

Configuration du provider

DoctrineServiceProvider.php

Enregistre Doctrine comme un service applicatif standard via un provider Lemonade.

DoctrineServiceProvider.php
PHP
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Repository\OrmDemoArticleRepository;
use App\Repository\OrmDemoAuthorRepository;
use App\Repository\OrmDemoTagRepository;
use App\Services\DoctrineEntityManagerFactory;
use App\Services\OrmDemoService;
use Doctrine\ORM\EntityManagerInterface;
use Lemonade\Framework\Container\ContainerInterface;
use Lemonade\Framework\Core\ServiceProviderInterface;

final class DoctrineServiceProvider implements ServiceProviderInterface
{
    public function register(ContainerInterface $container): void
    {
        $container->singleton(DoctrineEntityManagerFactory::class, DoctrineEntityManagerFactory::class);
        $container->singleton(
            EntityManagerInterface::class,
            static fn (ContainerInterface $container): EntityManagerInterface => $container
                ->get(DoctrineEntityManagerFactory::class)
                ->create(),
        );
        $container->set(OrmDemoArticleRepository::class, OrmDemoArticleRepository::class);
        $container->set(OrmDemoTagRepository::class, OrmDemoTagRepository::class);
        $container->set(OrmDemoAuthorRepository::class, OrmDemoAuthorRepository::class);
        $container->singleton(OrmDemoService::class, OrmDemoService::class);
    }
}
Entité Article

OrmDemoArticle.php

Mappe plusieurs auteurs et plusieurs tags avec des associations ManyToMany.

OrmDemoArticle.php
PHP
<?php

declare(strict_types=1);

namespace App\Entity;

use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;

#[ORM\Entity]
#[ORM\Table(name: 'orm_demo_articles')]
final class OrmDemoArticle
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 255)]
    private string $title;

    #[ORM\Column(type: 'text')]
    private string $perex;

    /** @var Collection<int, OrmDemoAuthor> */
    #[ORM\ManyToMany(targetEntity: OrmDemoAuthor::class, inversedBy: 'articles', cascade: ['persist'])]
    #[ORM\JoinTable(name: 'orm_demo_article_authors')]
    private Collection $authors;

    #[ORM\Column(type: 'datetime_immutable')]
    private DateTimeImmutable $createdAt;

    /** @var Collection<int, OrmDemoTag> */
    #[ORM\ManyToMany(targetEntity: OrmDemoTag::class, inversedBy: 'articles', cascade: ['persist'])]
    #[ORM\JoinTable(name: 'orm_demo_article_tags')]
    private Collection $tags;

    /**
     * @param non-empty-list<OrmDemoAuthor> $authors
     */
    public function __construct(string $title, string $perex, array $authors, DateTimeImmutable $createdAt)
    {
        if ($authors === []) {
            throw new InvalidArgumentException('An ORM demo article must have at least one author.');
        }

        $this->title = $title;
        $this->perex = $perex;
        $this->createdAt = $createdAt;
        $this->authors = new ArrayCollection();
        $this->tags = new ArrayCollection();

        foreach ($authors as $author) {
            $this->addAuthor($author);
        }
    }

    public function id(): ?int
    {
        return $this->id;
    }

    public function title(): string
    {
        return $this->title;
    }

    public function perex(): string
    {
        return $this->perex;
    }

    /** @return list<OrmDemoAuthor> */
    public function authors(): array
    {
        return $this->authors->toArray();
    }

    public function addAuthor(OrmDemoAuthor $author): void
    {
        if (!$this->authors->contains($author)) {
            $this->authors->add($author);
        }
    }

    public function createdAt(): DateTimeImmutable
    {
        return $this->createdAt;
    }

    /** @return Collection<int, OrmDemoTag> */
    public function tags(): Collection
    {
        return $this->tags;
    }

    public function addTag(OrmDemoTag $tag): void
    {
        if (!$this->tags->contains($tag)) {
            $this->tags->add($tag);
        }
    }
}
Entité Author

OrmDemoAuthor.php

Les auteurs sont des entités distinctes identifiées par un nickname unique.

OrmDemoAuthor.php
PHP
<?php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'orm_demo_authors')]
final class OrmDemoAuthor
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 80, unique: true)]
    private string $nickname;

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

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

    public function id(): ?int
    {
        return $this->id;
    }

    public function nickname(): string
    {
        return $this->nickname;
    }
}
Entité Tag

OrmDemoTag.php

Les tags sont des entités partagées reliées aux articles par une table de jointure.

OrmDemoTag.php
PHP
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'orm_demo_tags')]
final class OrmDemoTag
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private ?int $id = null;

    #[ORM\Column(type: 'string', length: 80)]
    private string $name;

    #[ORM\Column(type: 'string', length: 80, unique: true)]
    private string $slug;

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

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

    public function id(): ?int
    {
        return $this->id;
    }

    public function name(): string
    {
        return $this->name;
    }

    public function slug(): string
    {
        return $this->slug;
    }
}
Repository et pagination

OrmDemoArticleRepository.php

Combine des filtres facultatifs avec une pagination SQL et évite les requêtes N+1.

OrmDemoArticleRepository.php
PHP
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\OrmDemoArticle;
use App\Services\DoctrineEntityManagerFactory;
use Doctrine\ORM\EntityManagerInterface;

final class OrmDemoArticleRepository
{
    private const GRAPH_BATCH_SIZE = 25;

    public function __construct(private readonly DoctrineEntityManagerFactory $entityManagerFactory)
    {
    }

    public function paginate(int $page, int $perPage, ?string $tag = null, ?string $author = null): OrmDemoArticlePage
    {
        $perPage = max(1, $perPage);
        $count = $this->entityManager()
            ->createQueryBuilder()
            ->select('COUNT(DISTINCT article.id)')
            ->from(OrmDemoArticle::class, 'article');

        if ($tag !== null) {
            $count
                ->innerJoin('article.tags', 'tag')
                ->andWhere('tag.slug = :tag')
                ->setParameter('tag', $tag);
        }

        if ($author !== null) {
            $count
                ->innerJoin('article.authors', 'author')
                ->andWhere('author.nickname = :author')
                ->setParameter('author', $author);
        }

        $total = (int) $count->getQuery()->getSingleScalarResult();
        $page = min(max(1, $page), max(1, (int) ceil($total / $perPage)));

        $idsQuery = $this->entityManager()
            ->createQueryBuilder()
            ->select('DISTINCT article.id AS id')
            ->from(OrmDemoArticle::class, 'article');
        if ($tag !== null) {
            $idsQuery
                ->innerJoin('article.tags', 'tag')
                ->andWhere('tag.slug = :tag')
                ->setParameter('tag', $tag);
        }

        if ($author !== null) {
            $idsQuery
                ->innerJoin('article.authors', 'author')
                ->andWhere('author.nickname = :author')
                ->setParameter('author', $author);
        }

        $idsQuery
            ->orderBy('article.createdAt', 'DESC')
            ->addOrderBy('article.id', 'DESC')
            ->setFirstResult(($page - 1) * $perPage)
            ->setMaxResults($perPage);

        $articles = (function () use ($idsQuery): iterable {
            $entityManager = $this->entityManager();
            $ids = [];

            foreach ($idsQuery->getQuery()->toIterable() as $row) {
                $ids[] = (int) $row['id'];

                if (count($ids) === self::GRAPH_BATCH_SIZE) {
                    yield from $this->fetchGraphBatch($entityManager, $ids);
                    $ids = [];
                    $entityManager->clear();
                }
            }

            if ($ids !== []) {
                yield from $this->fetchGraphBatch($entityManager, $ids);
                $entityManager->clear();
            }
        })();

        return new OrmDemoArticlePage($articles, $total, $page);
    }

    /** @param list<int> $ids @return iterable<OrmDemoArticle> */
    private function fetchGraphBatch(EntityManagerInterface $entityManager, array $ids): iterable
    {
        /** @var list<OrmDemoArticle> $fetched */
        $fetched = $entityManager->createQueryBuilder()
            ->select('article, author, tag')
            ->from(OrmDemoArticle::class, 'article')
            ->leftJoin('article.authors', 'author')
            ->leftJoin('article.tags', 'tag')
            ->where('article.id IN (:ids)')
            ->setParameter('ids', $ids)
            ->getQuery()
            ->getResult();
        $byId = [];

        foreach ($fetched as $article) {
            $byId[$article->id() ?? 0] = $article;
        }

        foreach ($ids as $id) {
            if (isset($byId[$id])) {
                yield $byId[$id];
            }
        }
    }

    public function hasArticles(): bool
    {
        return $this->entityManager()->getRepository(OrmDemoArticle::class)->count([]) > 0;
    }

    public function add(OrmDemoArticle $article): void
    {
        $this->entityManager()->persist($article);
        $this->entityManager()->flush();
    }

    /** @param iterable<OrmDemoArticle> $articles */
    public function addAll(iterable $articles, int $batchSize): void
    {
        $entityManager = $this->entityManager();
        $processed = 0;

        $batchSize = max(1, $batchSize);

        foreach ($articles as $article) {
            $entityManager->persist($article);
            ++$processed;

            if ($processed % $batchSize === 0) {
                $entityManager->flush();
            }
        }

        $entityManager->flush();
    }

    private function entityManager(): EntityManagerInterface
    {
        return $this->entityManagerFactory->create();
    }
}

Fonctionnement du filtrage

Les articles peuvent avoir plusieurs auteurs et plusieurs tags. Les deux associations sont mappées dans Doctrine ORM comme ManyToMany. Lors du filtrage par auteur, Doctrine relie les articles via la table de jointure orm_demo_article_authors et les sélectionne selon author.nickname. Lors du filtrage par tag, il utilise la table de jointure orm_demo_article_tags avec une condition sur tag.slug. Les deux filtres peuvent être combinés.