ORM showcase

Lemonade Blog

A small blog powered by Doctrine ORM and SQLite.

Doctrine ORM · SQLite · ManyToMany

Live ORM result

Articles tagged “Php”

99 articles

Doctrine ORM as an application integration

Doctrine stays in app code and resolves only when it is needed.

Localized routes without duplicated controllers

Route groups keep URLs readable without cloning controllers.

Configuration with clear defaults

Typed configuration keeps environment choices visible.

Doctrine metadata belongs to the app

Entity mapping remains an application concern.

Doctrine ORM as an application integration — field note 0052

Doctrine stays in app code and resolves only when it is needed. This deterministic field note expands the demo dataset.

Localized routes without duplicated controllers — field note 0061

Route groups keep URLs readable without cloning controllers. This deterministic field note expands the demo dataset.

Configuration with clear defaults — field note 0065

Typed configuration keeps environment choices visible. This deterministic field note expands the demo dataset.

Doctrine metadata belongs to the app — field note 0085

Entity mapping remains an application concern. This deterministic field note expands the demo dataset.

Doctrine ORM as an application integration — field note 0102

Doctrine stays in app code and resolves only when it is needed. This deterministic field note expands the demo dataset.

Localized routes without duplicated controllers — field note 0111

Route groups keep URLs readable without cloning controllers. This deterministic field note expands the demo dataset.

Configuration with clear defaults — field note 0115

Typed configuration keeps environment choices visible. This deterministic field note expands the demo dataset.

Doctrine metadata belongs to the app — field note 0135

Entity mapping remains an application concern. This deterministic field note expands the demo dataset.

How this blog works

Doctrine ORM integration behind the example

The paginated blog feed reads current-page IDs through toIterable(); a bounded graph batch then loads authors and tags without N+1 queries.

Provider wiring

DoctrineServiceProvider.php

Registers Doctrine as a regular application service through a Lemonade provider.

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);
    }
}
Article entity

OrmDemoArticle.php

Maps multiple authors and multiple tags through ManyToMany associations.

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);
        }
    }
}
Author entity

OrmDemoAuthor.php

Authors are separate entities identified by a unique nickname.

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;
    }
}
Tag entity

OrmDemoTag.php

Tags are shared entities connected to articles through a join table.

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 and pagination

OrmDemoArticleRepository.php

Combines optional filters with SQL-level pagination and avoids N+1 queries.

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();
    }
}

How filtering works

Articles can have multiple authors and multiple tags. Both associations are mapped in Doctrine ORM as ManyToMany. When filtering by author, Doctrine connects articles through the join table orm_demo_article_authors and selects them by author.nickname. When filtering by tag, it uses the join table orm_demo_article_tags with a condition on tag.slug. Both filters can be combined.