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
- Doctrine ORM Documentation - Official documentation
- Doctrine DBAL Documentation - Database abstraction layer
- PHP Attributes RFC - PHP 8 attributes syntax
- Doctrine GitHub Repository - Source code and issues