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
}
}