# Doctrine ORM Doctrine ORM is an object-relational mapper for PHP 8.1+ that provides transparent persistence for PHP objects. It sits on top of a powerful database abstraction layer (DBAL) and allows developers to work with database records as PHP objects. One of its key features is the Doctrine Query Language (DQL), a proprietary object-oriented SQL dialect that provides powerful querying capabilities over your object model without requiring direct SQL manipulation. The ORM implements the Data Mapper pattern, enabling complete separation of domain/business logic from database persistence. Entities are PHP objects with identity that can be persisted and retrieved through the EntityManager, which serves as the primary interface for ORM operations. Doctrine uses metadata (via PHP attributes or XML) to describe how entities map to database tables, supporting complex relationships including one-to-one, one-to-many, many-to-one, and many-to-many associations. ## Installation and Configuration Install Doctrine ORM via Composer and configure the EntityManager with database connection settings and metadata paths. ```php 'pdo_mysql', 'user' => 'root', 'password' => '', 'dbname' => 'myapp', ], $config); // Obtain the EntityManager $entityManager = new EntityManager($connection, $config); ``` ## Entity Definition with Attributes Define entities using PHP 8 attributes to map classes to database tables with column types, primary keys, and generation strategies. ```php true])] private bool $isActive = true; public function getId(): ?int { return $this->id; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; } public function getPrice(): string { return $this->price; } public function setPrice(string $price): void { $this->price = $price; } public function getCreatedAt(): \DateTime { return $this->createdAt; } public function setCreatedAt(\DateTime $createdAt): void { $this->createdAt = $createdAt; } } ``` ## Persisting and Retrieving Entities Use the EntityManager to persist new entities, find existing ones, and flush changes to the database. ```php setName('Widget'); $product->setPrice('29.99'); $product->setCreatedAt(new DateTime()); $entityManager->persist($product); $entityManager->flush(); echo "Created Product with ID " . $product->getId() . "\n"; // Finding an entity by primary key $product = $entityManager->find(Product::class, 1); if ($product !== null) { echo "Found: " . $product->getName() . "\n"; } // Updating an entity (changes are tracked automatically) $product->setPrice('34.99'); $entityManager->flush(); // Only changed fields are updated // Removing an entity $entityManager->remove($product); $entityManager->flush(); ``` ## Entity Repository Methods Use repositories to query entities with built-in finder methods or custom repository classes. ```php getRepository(Product::class); // Find all entities $allProducts = $productRepository->findAll(); // Find by primary key $product = $productRepository->find(1); // Find by criteria $activeProducts = $productRepository->findBy( ['isActive' => true], // criteria ['createdAt' => 'DESC'], // ordering 10, // limit 0 // offset ); // Find single entity by criteria $product = $productRepository->findOneBy(['name' => 'Widget']); // Count matching entities $count = $productRepository->count(['isActive' => true]); // Magic finder methods (via __call) $product = $productRepository->findOneByName('Widget'); $products = $productRepository->findByIsActive(true); ``` ## Many-To-One Association Map many-to-one relationships where multiple entities reference a single related entity. ```php article; } public function setArticle(?Article $article): void { $this->article = $article; } } // Usage $article = $entityManager->find(Article::class, 1); $comment = new Comment(); $comment->setContent('Great article!'); $comment->setArticle($article); $entityManager->persist($comment); $entityManager->flush(); ``` ## One-To-Many Bidirectional Association Define bidirectional one-to-many relationships with collections on the inverse side. ```php */ #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article', cascade: ['persist', 'remove'])] private Collection $comments; public function __construct() { $this->comments = new ArrayCollection(); } public function getComments(): Collection { return $this->comments; } public function addComment(Comment $comment): void { if (!$this->comments->contains($comment)) { $this->comments->add($comment); $comment->setArticle($this); } } public function removeComment(Comment $comment): void { if ($this->comments->removeElement($comment)) { $comment->setArticle(null); } } } // Usage $article = new Article(); $article->setTitle('Doctrine ORM Guide'); $comment = new Comment(); $comment->setContent('Very helpful!'); $article->addComment($comment); $entityManager->persist($article); $entityManager->flush(); ``` ## Many-To-Many Association Map many-to-many relationships using a join table with owning and inverse sides. ```php */ #[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')] #[ORM\JoinTable(name: 'user_roles')] private Collection $roles; public function __construct() { $this->roles = new ArrayCollection(); } public function addRole(Role $role): void { if (!$this->roles->contains($role)) { $this->roles->add($role); $role->addUser($this); } } } #[ORM\Entity] class Role { #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] private int|null $id = null; /** @var Collection */ #[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'roles')] private Collection $users; public function __construct() { $this->users = new ArrayCollection(); } public function addUser(User $user): void { if (!$this->users->contains($user)) { $this->users->add($user); } } } ``` ## DQL SELECT Queries Use Doctrine Query Language (DQL) to query entities with an object-oriented SQL dialect. ```php createQuery( 'SELECT u FROM App\Entity\User u WHERE u.age > :age' ); $query->setParameter('age', 18); $users = $query->getResult(); // JOIN with related entities (fetch join) $query = $entityManager->createQuery( 'SELECT a, c FROM App\Entity\Article a JOIN a.comments c WHERE a.isPublished = true ORDER BY a.createdAt DESC' ); $articles = $query->getResult(); // Aggregate functions $query = $entityManager->createQuery( 'SELECT COUNT(u.id) FROM App\Entity\User u WHERE u.isActive = true' ); $count = $query->getSingleScalarResult(); // Partial selection and grouping $query = $entityManager->createQuery( 'SELECT u.id, u.name, COUNT(o.id) AS orderCount FROM App\Entity\User u LEFT JOIN u.orders o GROUP BY u.id HAVING COUNT(o.id) > :minOrders' ); $query->setParameter('minOrders', 5); $results = $query->getResult(); // Pagination $query = $entityManager->createQuery('SELECT p FROM App\Entity\Product p'); $query->setFirstResult(0); $query->setMaxResults(10); $products = $query->getResult(); ``` ## DQL UPDATE and DELETE Queries Execute bulk update and delete operations directly in the database without loading entities. ```php createQuery( 'UPDATE App\Entity\User u SET u.status = :newStatus WHERE u.lastLogin < :date' ); $query->setParameter('newStatus', 'inactive'); $query->setParameter('date', new DateTime('-6 months')); $numUpdated = $query->execute(); echo "Updated $numUpdated users\n"; // Bulk DELETE $query = $entityManager->createQuery( 'DELETE FROM App\Entity\Session s WHERE s.expiresAt < :now' ); $query->setParameter('now', new DateTime()); $numDeleted = $query->execute(); echo "Deleted $numDeleted expired sessions\n"; // Clear identity map after bulk operations $entityManager->clear(); ``` ## QueryBuilder for Dynamic Queries Build DQL queries programmatically using the QueryBuilder fluent API. ```php createQueryBuilder(); // Basic query $qb->select('u') ->from('App\Entity\User', 'u') ->where('u.isActive = :active') ->orderBy('u.name', 'ASC') ->setParameter('active', true); $users = $qb->getQuery()->getResult(); // Complex query with conditions $qb = $entityManager->createQueryBuilder(); $qb->select('p') ->from('App\Entity\Product', 'p') ->where($qb->expr()->andX( $qb->expr()->gte('p.price', ':minPrice'), $qb->expr()->lte('p.price', ':maxPrice'), $qb->expr()->orX( $qb->expr()->like('p.name', ':search'), $qb->expr()->like('p.description', ':search') ) )) ->setParameter('minPrice', 10) ->setParameter('maxPrice', 100) ->setParameter('search', '%widget%'); $products = $qb->getQuery()->getResult(); // Dynamic query building $qb = $entityManager->createQueryBuilder() ->select('o') ->from('App\Entity\Order', 'o'); if ($status !== null) { $qb->andWhere('o.status = :status') ->setParameter('status', $status); } if ($customerId !== null) { $qb->andWhere('o.customer = :customer') ->setParameter('customer', $customerId); } $orders = $qb->getQuery()->getResult(); ``` ## Query Result Hydration Modes Control how query results are returned using different hydration modes for performance optimization. ```php createQuery($dql); // Object hydration (default) - returns entity objects $users = $query->getResult(); // or explicitly: $users = $query->getResult(AbstractQuery::HYDRATE_OBJECT); // Array hydration - returns nested arrays (faster for read-only) $usersArray = $query->getArrayResult(); // Scalar hydration - flat result set with scalar values $scalars = $query->getScalarResult(); // Single scalar result (for COUNT, SUM, etc.) $query = $entityManager->createQuery('SELECT COUNT(u.id) FROM App\Entity\User u'); $count = $query->getSingleScalarResult(); // Single result (throws exception if not exactly one result) $query = $entityManager->createQuery('SELECT u FROM App\Entity\User u WHERE u.email = :email'); $query->setParameter('email', 'john@example.com'); $user = $query->getSingleResult(); // Single result or null $user = $query->getOneOrNullResult(); // Column result (single column as flat array) $query = $entityManager->createQuery('SELECT u.id FROM App\Entity\User u'); $ids = $query->getSingleColumnResult(); ``` ## Custom Repository Classes Create custom repository classes to encapsulate complex query logic and domain-specific finder methods. ```php createQueryBuilder('u') ->where('u.isActive = true') ->orderBy('u.name', 'ASC') ->getQuery() ->getResult(); } public function findBySearchTerm(string $term, int $limit = 10): array { return $this->createQueryBuilder('u') ->where('u.name LIKE :term OR u.email LIKE :term') ->setParameter('term', '%' . $term . '%') ->setMaxResults($limit) ->getQuery() ->getResult(); } public function getStatistics(): array { return $this->createQueryBuilder('u') ->select('COUNT(u.id) as total, SUM(CASE WHEN u.isActive = true THEN 1 ELSE 0 END) as active') ->getQuery() ->getSingleResult(); } } // Entity configuration #[ORM\Entity(repositoryClass: UserRepository::class)] class User { // ... } // Usage $userRepo = $entityManager->getRepository(User::class); $activeUsers = $userRepo->findActiveUsers(); $searchResults = $userRepo->findBySearchTerm('john'); ``` ## Lifecycle Callbacks Define callback methods on entities that are triggered during persistence lifecycle events. ```php createdAt = new \DateTime(); $this->updatedAt = new \DateTime(); $this->generateSlug(); } #[ORM\PreUpdate] public function onPreUpdate(PreUpdateEventArgs $args): void { $this->updatedAt = new \DateTime(); if ($args->hasChangedField('title')) { $this->generateSlug(); } } private function generateSlug(): void { $this->slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $this->title)); } } ``` ## Event Listeners and Subscribers Register external listeners for lifecycle events across multiple entity types. ```php getObject(); if ($entity instanceof AuditableInterface) { $entity->setModifiedAt(new \DateTime()); $entity->setModifiedBy($this->getCurrentUser()); } } public function postPersist(PostPersistEventArgs $args): void { $entity = $args->getObject(); $this->logger->info('Entity persisted: ' . get_class($entity)); } } // Event Subscriber class TimestampSubscriber implements EventSubscriber { public function getSubscribedEvents(): array { return [Events::prePersist, Events::preUpdate]; } public function prePersist($args): void { $this->updateTimestamps($args->getObject()); } public function preUpdate($args): void { $this->updateTimestamps($args->getObject()); } } // Registration $eventManager = $entityManager->getEventManager(); $eventManager->addEventListener([Events::preUpdate, Events::postPersist], new AuditListener()); $eventManager->addEventSubscriber(new TimestampSubscriber()); ``` ## Transactions and Explicit Control Manage database transactions explicitly for complex operations requiring atomicity. ```php setName('John'); $entityManager->persist($user); $entityManager->flush(); // Transaction auto-committed // Explicit transaction control $entityManager->getConnection()->beginTransaction(); try { $user = new User(); $user->setName('John'); $entityManager->persist($user); $order = new Order(); $order->setUser($user); $entityManager->persist($order); $entityManager->flush(); $entityManager->getConnection()->commit(); } catch (\Exception $e) { $entityManager->getConnection()->rollBack(); throw $e; } // Using wrapInTransaction helper $entityManager->wrapInTransaction(function($em) { $user = new User(); $user->setName('Jane'); $em->persist($user); // flush is called automatically before commit }); ``` ## Optimistic Locking Implement optimistic locking to prevent concurrent modification conflicts in long-running transactions. ```php find( Document::class, $documentId, LockMode::OPTIMISTIC, $expectedVersion ); $document->setContent('Updated content'); $entityManager->flush(); } catch (OptimisticLockException $e) { echo "Document was modified by another user. Please reload and try again."; } // Or use lock() method $document = $entityManager->find(Document::class, $documentId); try { $entityManager->lock($document, LockMode::OPTIMISTIC, $expectedVersion); // Proceed with update } catch (OptimisticLockException $e) { // Handle conflict } ``` ## Native SQL Queries Execute raw SQL queries and map results to entities using ResultSetMapping. ```php addRootEntityFromClassMetadata('App\Entity\User', 'u'); $sql = "SELECT u.id, u.name, u.email FROM users u WHERE u.status = ?"; $query = $entityManager->createNativeQuery($sql, $rsm); $query->setParameter(1, 'active'); $users = $query->getResult(); // With joined entity $rsm = new ResultSetMappingBuilder($entityManager); $rsm->addRootEntityFromClassMetadata('App\Entity\User', 'u'); $rsm->addJoinedEntityFromClassMetadata( 'App\Entity\Address', 'a', 'u', 'address', ['id' => 'address_id'] ); $sql = "SELECT u.id, u.name, a.id AS address_id, a.city FROM users u INNER JOIN addresses a ON u.address_id = a.id"; $query = $entityManager->createNativeQuery($sql, $rsm); $users = $query->getResult(); // Manual ResultSetMapping for complex queries $rsm = new ResultSetMapping(); $rsm->addEntityResult('App\Entity\User', 'u'); $rsm->addFieldResult('u', 'id', 'id'); $rsm->addFieldResult('u', 'name', 'name'); $rsm->addScalarResult('order_count', 'orderCount'); $sql = "SELECT u.id, u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id"; $query = $entityManager->createNativeQuery($sql, $rsm); $results = $query->getResult(); ``` ## Schema Tool Commands Use command-line tools to manage database schema based on entity mappings. ```php getMetadataFactory()->getAllMetadata(); // Create schema $schemaTool->createSchema($classes); // Update schema $schemaTool->updateSchema($classes); // Get SQL without executing $sqls = $schemaTool->getUpdateSchemaSql($classes); foreach ($sqls as $sql) { echo $sql . "\n"; } ``` ## Inheritance Mapping Map class hierarchies to database tables using single table or class table inheritance strategies. ```php Person::class, 'employee' => Employee::class])] class Person { #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] protected int|null $id = null; #[ORM\Column(type: 'string')] protected string $name; } #[ORM\Entity] class Employee extends Person { #[ORM\Column(type: 'string')] private string $department; } // Class Table Inheritance - separate tables per class #[ORM\Entity] #[ORM\InheritanceType('JOINED')] #[ORM\DiscriminatorColumn(name: 'type', type: 'string')] #[ORM\DiscriminatorMap(['vehicle' => Vehicle::class, 'car' => Car::class, 'truck' => Truck::class])] class Vehicle { #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] protected int|null $id = null; #[ORM\Column(type: 'string')] protected string $brand; } #[ORM\Entity] class Car extends Vehicle { #[ORM\Column(type: 'integer')] private int $seats; } // Query by type $query = $entityManager->createQuery('SELECT e FROM App\Entity\Employee e'); $employees = $query->getResult(); ``` ## Summary Doctrine ORM is the standard choice for database abstraction in PHP applications, particularly those built with Symfony. Its primary use cases include building data-driven web applications where domain objects need transparent database persistence, implementing complex domain models with rich relationships between entities, and developing applications requiring database portability across MySQL, PostgreSQL, SQLite, and other platforms. The ORM excels in scenarios requiring sophisticated querying through DQL, transactional integrity, and caching of entity metadata and query results. Integration patterns typically involve configuring the EntityManager as a shared service in dependency injection containers, defining entity mappings via PHP attributes for modern codebases or XML for configuration-driven approaches, and implementing the Repository pattern for encapsulating query logic. Applications commonly leverage lifecycle events for audit logging and automatic timestamp management, custom hydration modes for optimizing read-heavy operations, and the QueryBuilder for dynamic search functionality. For high-performance scenarios, developers combine DQL fetch joins to minimize N+1 queries, array hydration for read-only views, and native SQL for vendor-specific optimizations while maintaining the benefits of object mapping.