NexusCS

Doctrine ORM

PHP
Quick reference for Doctrine ORM with PHP 8+ attributes syntax
php
orm
database
sql

Getting started

Introduction

Doctrine ORM is a powerful object-relational mapper for PHP that implements the Data Mapper pattern.

Installation

composer require doctrine/orm
composer require doctrine/dbal

Basic Entity

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

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

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

    // Getters and setters...
}

EntityManager Basics

// Persist new entity
$user = new User();
$user->setName('John Doe');
$entityManager->persist($user);
$entityManager->flush();

// Find by ID
$user = $entityManager->find(User::class, 1);

// Remove entity
$entityManager->remove($user);
$entityManager->flush();

Entity Definition

Column Types

Type PHP Type Example
string string #[Column(type: 'string', length: 255)]
integer int #[Column(type: 'integer')]
decimal string #[Column(type: 'decimal', precision: 10, scale: 2)]
boolean bool #[Column(type: 'boolean')]
text string #[Column(type: 'text')]
datetime DateTime #[Column(type: 'datetime')]
datetime_immutable DateTimeImmutable #[Column(type: 'datetime_immutable')]
json array #[Column(type: 'json')]

Primary Keys

// Auto-increment ID
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;

// UUID
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
private string $id;

// Manual ID
#[ORM\Id]
#[ORM\Column(type: 'string', length: 20)]
private string $code;

Column Options

#[ORM\Column(
    type: 'string',
    length: 255,
    nullable: true,
    unique: false,
    options: ['default' => 'N/A']
)]
private ?string $description;

// Not mapped to database
private string $temporaryValue;

// Computed property
#[ORM\Column(insertable: false, updatable: false)]
private int $calculatedField;

Table Configuration

#[ORM\Entity]
#[ORM\Table(
    name: 'users',
    indexes: [
        new ORM\Index(name: 'email_idx', columns: ['email']),
        new ORM\Index(name: 'name_idx', columns: ['name'])
    ],
    uniqueConstraints: [
        new ORM\UniqueConstraint(
            name: 'username_unique',
            columns: ['username']
        )
    ]
)]
class User
{
    // ...
}

Associations

ManyToOne

#[ORM\Entity]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: false)]
    private User $author;

    public function getAuthor(): User
    {
        return $this->author;
    }

    public function setAuthor(User $author): void
    {
        $this->author = $author;
    }
}

OneToMany (Bidirectional)

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

#[ORM\Entity]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\OneToMany(
        targetEntity: Article::class,
        mappedBy: 'author',
        cascade: ['persist', 'remove']
    )]
    private Collection $articles;

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

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

    public function addArticle(Article $article): void
    {
        if (!$this->articles->contains($article)) {
            $this->articles[] = $article;
            $article->setAuthor($this);
        }
    }
}

ManyToMany

#[ORM\Entity]
class Article
{
    #[ORM\ManyToMany(targetEntity: Tag::class)]
    #[ORM\JoinTable(name: 'article_tags')]
    private Collection $tags;

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

    public function addTag(Tag $tag): void
    {
        if (!$this->tags->contains($tag)) {
            $this->tags[] = $tag;
        }
    }

    public function removeTag(Tag $tag): void
    {
        $this->tags->removeElement($tag);
    }
}

OneToOne

#[ORM\Entity]
class User
{
    #[ORM\OneToOne(
        targetEntity: Profile::class,
        cascade: ['persist', 'remove']
    )]
    #[ORM\JoinColumn(nullable: false)]
    private Profile $profile;
}

#[ORM\Entity]
class Profile
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\OneToOne(
        targetEntity: User::class,
        mappedBy: 'profile'
    )]
    private User $user;
}

EntityManager Operations

Persisting Entities

// Create new entity
$user = new User();
$user->setName('Jane Doe');
$user->setEmail('jane@example.com');

// Mark for insertion
$entityManager->persist($user);

// Execute SQL INSERT
$entityManager->flush();

// ID is now available
echo $user->getId();

Finding Entities

// Find by primary key
$user = $entityManager->find(User::class, 1);

// Find one by criteria
$user = $entityManager
    ->getRepository(User::class)
    ->findOneBy(['email' => 'jane@example.com']);

// Find all by criteria
$users = $entityManager
    ->getRepository(User::class)
    ->findBy(['status' => 'active'], ['name' => 'ASC']);

// Find all
$users = $entityManager
    ->getRepository(User::class)
    ->findAll();

Updating Entities

// Fetch entity
$user = $entityManager->find(User::class, 1);

// Modify properties
$user->setName('Updated Name');
$user->setEmail('updated@example.com');

// No persist() needed for existing entities
// Just flush to save changes
$entityManager->flush();

Removing Entities

$user = $entityManager->find(User::class, 1);

// Mark for deletion
$entityManager->remove($user);

// Execute SQL DELETE
$entityManager->flush();

Detaching & Merging

// Detach from EntityManager
$entityManager->detach($user);

// Changes won't be persisted
$user->setName('This wont save');
$entityManager->flush();

// Merge back into context
$managedUser = $entityManager->merge($user);
$managedUser->setName('This will save');
$entityManager->flush();

Refreshing Entities

// Reload from database
$entityManager->refresh($user);

// Discard in-memory changes
$user->setName('Temporary');
$entityManager->refresh($user);
// Name reverts to database value

Querying with DQL

Basic DQL

// Simple SELECT
$dql = 'SELECT u FROM App\Entity\User u WHERE u.status = :status';
$query = $entityManager->createQuery($dql);
$query->setParameter('status', 'active');
$users = $query->getResult();

// Single result
$dql = 'SELECT u FROM App\Entity\User u WHERE u.email = :email';
$user = $entityManager->createQuery($dql)
    ->setParameter('email', 'john@example.com')
    ->getSingleResult();

// Scalar result
$dql = 'SELECT COUNT(u.id) FROM App\Entity\User u';
$count = $entityManager->createQuery($dql)
    ->getSingleScalarResult();

DQL with Joins

// INNER JOIN
$dql = 'SELECT a, u FROM App\Entity\Article a
        JOIN a.author u
        WHERE u.status = :status';
$articles = $entityManager->createQuery($dql)
    ->setParameter('status', 'active')
    ->getResult();

// LEFT JOIN
$dql = 'SELECT u, a FROM App\Entity\User u
        LEFT JOIN u.articles a';
$users = $entityManager->createQuery($dql)
    ->getResult();

DQL Aggregates

// GROUP BY with COUNT
$dql = 'SELECT u.name, COUNT(a.id) as articleCount
        FROM App\Entity\User u
        LEFT JOIN u.articles a
        GROUP BY u.id
        HAVING COUNT(a.id) > :minCount';
$results = $entityManager->createQuery($dql)
    ->setParameter('minCount', 5)
    ->getResult();

Pagination

$dql = 'SELECT u FROM App\Entity\User u ORDER BY u.createdAt DESC';
$query = $entityManager->createQuery($dql);

// Set pagination
$query->setFirstResult(0);     // Offset
$query->setMaxResults(20);      // Limit

$users = $query->getResult();

QueryBuilder

Basic QueryBuilder

$qb = $entityManager->createQueryBuilder();

$users = $qb->select('u')
    ->from(User::class, 'u')
    ->where('u.status = :status')
    ->setParameter('status', 'active')
    ->orderBy('u.name', 'ASC')
    ->getQuery()
    ->getResult();

Conditions

$qb = $entityManager->createQueryBuilder();

$qb->select('u')
    ->from(User::class, 'u')
    ->where('u.age > :minAge')
    ->andWhere('u.status = :status')
    ->orWhere('u.role = :role')
    ->setParameter('minAge', 18)
    ->setParameter('status', 'active')
    ->setParameter('role', 'admin');

$users = $qb->getQuery()->getResult();

Complex Conditions

$qb = $entityManager->createQueryBuilder();

$qb->select('u')
    ->from(User::class, 'u')
    ->where(
        $qb->expr()->orX(
            $qb->expr()->eq('u.role', ':admin'),
            $qb->expr()->andX(
                $qb->expr()->eq('u.role', ':user'),
                $qb->expr()->gte('u.points', ':minPoints')
            )
        )
    )
    ->setParameter('admin', 'ADMIN')
    ->setParameter('user', 'USER')
    ->setParameter('minPoints', 100);

Joins in QueryBuilder

$qb = $entityManager->createQueryBuilder();

// INNER JOIN
$qb->select('a', 'u')
    ->from(Article::class, 'a')
    ->innerJoin('a.author', 'u')
    ->where('u.status = :status')
    ->setParameter('status', 'active');

// LEFT JOIN with condition
$qb->select('u')
    ->from(User::class, 'u')
    ->leftJoin('u.articles', 'a', 'WITH', 'a.published = true')
    ->where('a.id IS NOT NULL');

QueryBuilder Expressions

$qb = $entityManager->createQueryBuilder();
$expr = $qb->expr();

$qb->select('u')
    ->from(User::class, 'u')
    ->where($expr->like('u.email', ':email'))
    ->andWhere($expr->in('u.role', ':roles'))
    ->andWhere($expr->isNotNull('u.lastLogin'))
    ->andWhere($expr->between('u.age', ':minAge', ':maxAge'))
    ->setParameter('email', '%@example.com')
    ->setParameter('roles', ['USER', 'ADMIN'])
    ->setParameter('minAge', 18)
    ->setParameter('maxAge', 65);

Subqueries

$subQb = $entityManager->createQueryBuilder();
$subQb->select('COUNT(a.id)')
    ->from(Article::class, 'a')
    ->where('a.author = u.id');

$qb = $entityManager->createQueryBuilder();
$qb->select('u')
    ->from(User::class, 'u')
    ->where($qb->expr()->gt(
        '(' . $subQb->getDQL() . ')',
        ':minArticles'
    ))
    ->setParameter('minArticles', 10);

Repositories

Custom Repository

use Doctrine\ORM\EntityRepository;

class UserRepository extends EntityRepository
{
    public function findActiveUsers(): array
    {
        return $this->createQueryBuilder('u')
            ->where('u.status = :status')
            ->setParameter('status', 'active')
            ->orderBy('u.name', 'ASC')
            ->getQuery()
            ->getResult();
    }

    public function findByRole(string $role): array
    {
        $qb = $this->createQueryBuilder('u');

        return $qb->where('u.role = :role')
            ->setParameter('role', $role)
            ->getQuery()
            ->getResult();
    }
}

Repository Configuration

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
class User
{
    // Entity definition...
}

Using Custom Repository

// Get repository
$repository = $entityManager->getRepository(User::class);

// Use custom methods
$activeUsers = $repository->findActiveUsers();
$admins = $repository->findByRole('ADMIN');

// Built-in methods still available
$user = $repository->find(1);
$users = $repository->findAll();

Repository with Joins

class ArticleRepository extends EntityRepository
{
    public function findWithAuthor(int $id): ?Article
    {
        return $this->createQueryBuilder('a')
            ->select('a', 'u')
            ->innerJoin('a.author', 'u')
            ->where('a.id = :id')
            ->setParameter('id', $id)
            ->getQuery()
            ->getOneOrNullResult();
    }

    public function findPublishedWithTags(): array
    {
        return $this->createQueryBuilder('a')
            ->select('a', 't')
            ->leftJoin('a.tags', 't')
            ->where('a.published = true')
            ->orderBy('a.createdAt', 'DESC')
            ->getQuery()
            ->getResult();
    }
}

Lifecycle Events

Event Attributes

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class Article
{
    #[ORM\Column(type: 'datetime')]
    private \DateTime $createdAt;

    #[ORM\Column(type: 'datetime')]
    private \DateTime $updatedAt;

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

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

Available Events

Event Timing Use Case
#[PrePersist] Before INSERT Set creation timestamp
#[PostPersist] After INSERT Send notifications
#[PreUpdate] Before UPDATE Update modified timestamp
#[PostUpdate] After UPDATE Clear cache
#[PreRemove] Before DELETE Archive related data
#[PostRemove] After DELETE Delete files
#[PostLoad] After entity loaded Initialize transient fields

Lifecycle Callback Example

#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class User
{
    #[ORM\Column(type: 'string')]
    private string $password;

    #[ORM\Column(type: 'string', nullable: true)]
    private ?string $token;

    #[ORM\PrePersist]
    #[ORM\PreUpdate]
    public function hashPassword(): void
    {
        if ($this->password) {
            $this->password = password_hash(
                $this->password,
                PASSWORD_BCRYPT
            );
        }
    }

    #[ORM\PostLoad]
    public function generateToken(): void
    {
        if (!$this->token) {
            $this->token = bin2hex(random_bytes(32));
        }
    }
}

Event Listeners

use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;

class EntityListener
{
    public function prePersist(
        Article $article,
        PrePersistEventArgs $event
    ): void {
        // Custom logic before persist
        $article->setSlug($this->generateSlug($article->getTitle()));
    }

    public function postUpdate(
        Article $article,
        PostUpdateEventArgs $event
    ): void {
        // Custom logic after update
        // Clear cache, send notifications, etc.
    }

    private function generateSlug(string $title): string
    {
        return strtolower(str_replace(' ', '-', $title));
    }
}

Common Attributes Reference

Entity Attributes

Attribute Usage
#[Entity] Mark class as entity
#[Table(name: 'table_name')] Set table name
#[Index] Add table index
#[UniqueConstraint] Add unique constraint

Field Attributes

Attribute Usage
#[Column] Map property to column
#[Id] Mark as primary key
#[GeneratedValue] Auto-generate value
#[SequenceGenerator] Use sequence for ID

Relationship Attributes

Attribute Usage
#[ManyToOne] Many-to-one association
#[OneToMany] One-to-many association
#[ManyToMany] Many-to-many association
#[OneToOne] One-to-one association
#[JoinColumn] Configure join column
#[JoinTable] Configure join table
#[OrderBy] Order collection

Lifecycle Attributes

Attribute Usage
#[HasLifecycleCallbacks] Enable lifecycle callbacks
#[PrePersist] Before INSERT
#[PostPersist] After INSERT
#[PreUpdate] Before UPDATE
#[PostUpdate] After UPDATE
#[PreRemove] Before DELETE
#[PostRemove] After DELETE
#[PostLoad] After entity loaded

Advanced Features

Embeddables

use Doctrine\ORM\Mapping as ORM;

#[ORM\Embeddable]
class Address
{
    #[ORM\Column(type: 'string')]
    private string $street;

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

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

    // Getters and setters...
}

#[ORM\Entity]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Embedded(class: Address::class)]
    private Address $address;

    public function __construct()
    {
        $this->address = new Address();
    }
}

Inheritance Mapping

// Single Table Inheritance
#[ORM\Entity]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'string')]
#[ORM\DiscriminatorMap([
    'person' => Person::class,
    'employee' => Employee::class
])]
abstract class Person
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    protected int $id;

    #[ORM\Column(type: 'string')]
    protected string $name;
}

#[ORM\Entity]
class Employee extends Person
{
    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $salary;
}

Cascade Operations

#[ORM\Entity]
class User
{
    #[ORM\OneToMany(
        targetEntity: Article::class,
        mappedBy: 'author',
        cascade: ['persist', 'remove']
    )]
    private Collection $articles;

    // When user is persisted, articles auto-persist
    // When user is removed, articles auto-remove
}

Fetch Modes

// LAZY (default) - Load on access
#[ORM\ManyToOne(
    targetEntity: User::class,
    fetch: 'LAZY'
)]
private User $author;

// EAGER - Load immediately
#[ORM\ManyToOne(
    targetEntity: User::class,
    fetch: 'EAGER'
)]
private User $author;

// EXTRA_LAZY - For collections only
#[ORM\OneToMany(
    targetEntity: Article::class,
    mappedBy: 'author',
    fetch: 'EXTRA_LAZY'
)]
private Collection $articles;

Transactional Operations

// Manual transaction
$entityManager->beginTransaction();

try {
    $user = new User();
    $user->setName('John');
    $entityManager->persist($user);

    $article = new Article();
    $article->setAuthor($user);
    $entityManager->persist($article);

    $entityManager->flush();
    $entityManager->commit();
} catch (\Exception $e) {
    $entityManager->rollback();
    throw $e;
}

// Transactional wrapper
$entityManager->transactional(function($em) {
    $user = new User();
    $user->setName('Jane');
    $em->persist($user);
});

Best Practices

Collection Initialization

use Doctrine\Common\Collections\ArrayCollection;

#[ORM\Entity]
class User
{
    #[ORM\OneToMany(
        targetEntity: Article::class,
        mappedBy: 'author'
    )]
    private Collection $articles;

    // Always initialize in constructor
    public function __construct()
    {
        $this->articles = new ArrayCollection();
    }
}

Avoid Foreign Key Fields

// ❌ BAD - Don't map foreign keys
#[ORM\Column(type: 'integer')]
private int $authorId;

// ✅ GOOD - Map relationships
#[ORM\ManyToOne(targetEntity: User::class)]
private User $author;

Repository Pattern

// ❌ BAD - Query in controller
$dql = 'SELECT u FROM App\Entity\User u WHERE u.status = :status';
$users = $entityManager->createQuery($dql)
    ->setParameter('status', 'active')
    ->getResult();

// ✅ GOOD - Use repository
$users = $userRepository->findActiveUsers();

Batch Processing

// Process large datasets efficiently
$batchSize = 20;
$i = 0;

$query = $entityManager->createQuery('SELECT u FROM App\Entity\User u');
$iterableResult = $query->toIterable();

foreach ($iterableResult as $user) {
    $user->increasePoints(10);

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

    $i++;
}

// Final flush
$entityManager->flush();
$entityManager->clear();

Avoid N+1 Queries

// ❌ BAD - N+1 problem
$articles = $articleRepository->findAll();
foreach ($articles as $article) {
    echo $article->getAuthor()->getName(); // Extra query per article
}

// ✅ GOOD - Use join fetch
$articles = $entityManager->createQuery(
    'SELECT a, u FROM App\Entity\Article a JOIN a.author u'
)->getResult();

foreach ($articles as $article) {
    echo $article->getAuthor()->getName(); // No extra query
}

Detach Unused Entities

// Clear EntityManager after batch operations
$entityManager->clear();

// Detach specific entity
$entityManager->detach($user);

// Prevents memory issues with large datasets

Gotchas

Collections Must Be Initialized

Always initialize collections in the constructor. Accessing uninitialized collections throws errors.

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

Flush Applies to All Entities

flush() executes all pending operations in the UnitOfWork, not just one entity. Be mindful of unintended side effects.

Avoid Composite Primary Keys

Doctrine supports composite keys but they're complex and limit features. Use auto-increment IDs when possible.

DateTime Objects Are Mutable

Using DateTime in entities can cause unexpected updates. Prefer DateTimeImmutable for value objects.

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

Lazy Loading Outside Request Context

Lazy-loaded associations won't work if the EntityManager is closed or the entity is detached (e.g., in background jobs).

Cascade Remove Dangers

cascade: ['remove'] recursively deletes related entities. Be careful with bidirectional associations to avoid accidental data loss.

Also see