Doctrine ORM — Data Mapper pattern

Doctrine utilise le pattern Data Mapper (contrairement à Active Record). Vos entités sont de simples objets PHP — Doctrine les mappe en base via l'EntityManager.
EntityManager

Orchestre la persistance — persist, flush, remove

Repository

Requêtes sur une entité — find, findBy, QueryBuilder

Unit of Work

Suit les changements — flush() génère le SQL minimal

Définir une entité

<?php
namespace App\Entity;

use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ORM\Table(name: 'articles')]
#[ORM\HasLifecycleCallbacks]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

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

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $body = null;

    #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
    private bool $published = false;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;

    #[ORM\PrePersist]
    public function onPrePersist(): void
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]
    public function onPreUpdate(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }

    // Getters et setters...
    public function getId(): ?int { return $this->id; }
    public function getTitle(): string { return $this->title; }
    public function setTitle(string $t): static { $this->title = $t; return $this; }
}

Types Doctrine courants

<?php
use Doctrine\DBAL\Types\Types;

// Types scalaires
#[ORM\Column(type: Types::STRING, length: 255)]
#[ORM\Column(type: Types::TEXT)]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\Column(type: Types::FLOAT)]
#[ORM\Column(type: Types::BOOLEAN)]
#[ORM\Column(type: Types::JSON)]      // array PHP ↔ JSON SQL

// Types date/heure
#[ORM\Column(type: Types::DATETIME_MUTABLE)]        // DateTime
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]      // DateTimeImmutable
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)]  // ex: prix

// Enum PHP 8.1
#[ORM\Column(enumType: StatusEnum::class)]

Relations entre entités

ManyToOne / OneToMany

<?php
// Article —[ManyToOne]—> Category
class Article
{
    #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    private Category $category;
}

// Category —[OneToMany]—> Article
class Category
{
    #[ORM\OneToMany(targetEntity: Article::class, mappedBy: 'category', cascade: ['persist', 'remove'])]
    private Collection $articles;

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

    public function getArticles(): Collection { return $this->articles; }

    public function addArticle(Article $a): static
    {
        if (!$this->articles->contains($a)) {
            $this->articles->add($a);
            $a->setCategory($this);
        }
        return $this;
    }
}

ManyToMany

<?php
// Article <—[ManyToMany]—> Tag
class Article
{
    #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
    #[ORM\JoinTable(name: 'article_tags')]
    private Collection $tags;
}

class Tag
{
    #[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
    private Collection $articles;
}

OneToOne

<?php
class User
{
    #[ORM\OneToOne(targetEntity: Profile::class, cascade: ['persist', 'remove'])]
    #[ORM\JoinColumn(nullable: true)]
    private ?Profile $profile = null;
}

Migrations Doctrine

# Créer une migration (diff schema BDD vs entités)
php bin/console make:migration

# Exécuter les migrations en attente
php bin/console doctrine:migrations:migrate

# Statut des migrations
php bin/console doctrine:migrations:status

# Annuler la dernière migration
php bin/console doctrine:migrations:migrate prev

# Régénérer toutes les migrations (dev — schéma vierge)
php bin/console doctrine:migrations:migrate --all-or-nothing
<?php
// migrations/Version20260520120000.php
namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260520120000 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
        $this->addSql('CREATE TABLE articles (
            id INT AUTO_INCREMENT NOT NULL,
            category_id INT NOT NULL,
            title VARCHAR(255) NOT NULL,
            body LONGTEXT DEFAULT NULL,
            published TINYINT(1) DEFAULT 0 NOT NULL,
            created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
            PRIMARY KEY(id)
        )');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP TABLE articles');
    }
}

Repositories

<?php
namespace App\Repository;

use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }

    // Méthodes héritées (ServiceEntityRepository)
    // $repo->find($id)
    // $repo->findAll()
    // $repo->findBy(['published' => true], ['createdAt' => 'DESC'], 10, 0)
    // $repo->findOneBy(['slug' => 'mon-article'])

    // Méthodes personnalisées
    public function findPublished(int $limit = 10): array
    {
        return $this->createQueryBuilder('a')
            ->andWhere('a.published = :pub')
            ->setParameter('pub', true)
            ->orderBy('a.createdAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }

    public function findByCategory(int $categoryId): array
    {
        return $this->findBy(['category' => $categoryId]);
    }
}

Utilisation dans un Controller

<?php
class ArticleController extends AbstractController
{
    public function __construct(
        private readonly ArticleRepository $articleRepo,
        private readonly EntityManagerInterface $em,
    ) {}

    public function index(): Response
    {
        $articles = $this->articleRepo->findPublished(10);
        return $this->render('article/index.html.twig', ['articles' => $articles]);
    }

    public function create(Request $request): Response
    {
        $article = new Article();
        $article->setTitle('Nouveau');
        $this->em->persist($article);
        $this->em->flush();

        return $this->redirectToRoute('article_index');
    }

    public function delete(Article $article): Response
    {
        $this->em->remove($article);
        $this->em->flush();
        return $this->redirectToRoute('article_index');
    }
}

QueryBuilder

<?php
public function search(string $q, string $sortField = 'createdAt'): array
{
    $allowedSorts = ['createdAt', 'title', 'id'];
    $sort = in_array($sortField, $allowedSorts, true) ? $sortField : 'createdAt';

    return $this->createQueryBuilder('a')
        ->leftJoin('a.category', 'c')
        ->addSelect('c')                                      // eager load
        ->andWhere('a.published = true')
        ->andWhere('LOWER(a.title) LIKE :q OR LOWER(a.body) LIKE :q')
        ->setParameter('q', '%' . strtolower($q) . '%')
        ->orderBy('a.' . $sort, 'DESC')
        ->setMaxResults(20)
        ->getQuery()
        ->getResult();
}

// Pagination
public function paginate(int $page, int $perPage = 10): array
{
    $qb = $this->createQueryBuilder('a')
        ->orderBy('a.createdAt', 'DESC')
        ->setFirstResult(($page - 1) * $perPage)
        ->setMaxResults($perPage);

    return $qb->getQuery()->getResult();
}

// COUNT
public function countPublished(): int
{
    return (int) $this->createQueryBuilder('a')
        ->select('COUNT(a.id)')
        ->andWhere('a.published = true')
        ->getQuery()
        ->getSingleScalarResult();
}

DQL — Doctrine Query Language

<?php
// DQL ressemble à SQL mais opère sur les entités (pas les tables)
$em = $this->getEntityManager();

$articles = $em->createQuery(
    'SELECT a, c FROM App\Entity\Article a
     JOIN a.category c
     WHERE a.published = true
     ORDER BY a.createdAt DESC'
)->setMaxResults(10)->getResult();

// Avec paramètres nommés
$result = $em->createQuery(
    'SELECT a FROM App\Entity\Article a WHERE a.title LIKE :title'
)->setParameter('title', '%Symfony%')->getResult();

// UPDATE en masse (pas de chargement en mémoire)
$em->createQuery(
    'UPDATE App\Entity\Article a SET a.published = true WHERE a.createdAt < :date'
)->setParameter('date', new \DateTimeImmutable('-30 days'))->execute();

// DELETE en masse
$em->createQuery(
    'DELETE FROM App\Entity\Article a WHERE a.published = false AND a.createdAt < :date'
)->setParameter('date', new \DateTimeImmutable('-1 year'))->execute();

Events & Lifecycle Callbacks

<?php
// Lifecycle callbacks sur l'entité (simple)
#[ORM\HasLifecycleCallbacks]
class Article
{
    #[ORM\PrePersist]
    public function onPrePersist(): void { $this->createdAt = new \DateTimeImmutable(); }

    #[ORM\PreUpdate]
    public function onPreUpdate(): void  { $this->updatedAt = new \DateTimeImmutable(); }

    #[ORM\PostRemove]
    public function onPostRemove(): void { /* nettoyage après suppression */ }
}
<?php
// Event Listener Doctrine (service séparé)
namespace App\EventListener;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use App\Entity\Article;

#[AsDoctrineListener(event: Events::postPersist)]
class ArticleListener
{
    public function postPersist(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof Article) return;

        // ex: envoyer une notification
    }
}
▶ Mini-projet SF04 🧠 QCM SF04 Module 05 →