Doctrine ORM: Làm chủ các quan hệ trong Symfony
Hướng dẫn đầy đủ về quan hệ Doctrine ORM trong Symfony. OneToMany, ManyToMany, chiến lược tải và tối ưu hiệu năng kèm ví dụ thực tế.

Quan hệ giữa các thực thể là xương sống của mọi ứng dụng Symfony sử dụng Doctrine ORM. Nắm vững các loại quan hệ, chiến lược tải và những cạm bẫy về hiệu năng giúp xây dựng các ứng dụng vững chắc và tối ưu. Hướng dẫn này trình bày các mẫu thiết kế thiết yếu để quản lý quan hệ Doctrine một cách hiệu quả.
Doctrine sử dụng khái niệm phía sở hữu và phía nghịch đảo. Hiểu rõ sự khác biệt này giúp tránh nhiều lỗi liên quan đến việc lưu trữ quan hệ.
Các loại quan hệ trong Doctrine
Doctrine cung cấp bốn loại quan hệ giữa các thực thể: OneToOne, OneToMany, ManyToOne và ManyToMany. Mỗi loại đáp ứng một nhu cầu nghiệp vụ riêng và có đặc tính hiệu năng khác nhau.
Quan hệ ManyToOne: trường hợp phổ biến nhất
Quan hệ ManyToOne thể hiện mối liên kết phổ biến nhất trong cơ sở dữ liệu. Nhiều thực thể ở một bên liên kết với một thực thể duy nhất ở bên kia. Quan hệ này luôn là phía sở hữu của mối liên kết.
// A comment belongs to a single article
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CommentRepository::class)]
class Comment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: 'text')]
private string $content;
// ManyToOne: many comments → one article
// This entity owns the relationship (foreign key stored here)
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)] // Relationship is required
private Article $article;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getArticle(): Article
{
return $this->article;
}
public function setArticle(Article $article): self
{
$this->article = $article;
return $this;
}
}Thực thể Comment lưu khóa ngoại article_id. Doctrine xử lý việc lưu trữ tự động: gọi $comment->setArticle($article) sẽ tạo liên kết trong cơ sở dữ liệu.
Quan hệ OneToMany: phía nghịch đảo
Quan hệ OneToMany thể hiện phía nghịch đảo của ManyToOne. Nó cho phép điều hướng từ thực thể «một» đến các thực thể «nhiều». Quan hệ này không bao giờ là phía sở hữu và không chứa khóa ngoại.
// An article has multiple comments
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text')]
private string $content;
// OneToMany: one article → many comments
// Inverse side: mappedBy points to the owning entity's property
#[ORM\OneToMany(
targetEntity: Comment::class,
mappedBy: 'article',
cascade: ['persist', 'remove'], // Cascade operations
orphanRemoval: true // Remove orphaned comments
)]
private Collection $comments;
public function __construct()
{
// Initialize collection in the constructor
$this->comments = new ArrayCollection();
}
/**
* @return Collection<int, Comment>
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
// CRITICAL: synchronize the owning side
$comment->setArticle($this);
}
return $this;
}
public function removeComment(Comment $comment): self
{
if ($this->comments->removeElement($comment)) {
// orphanRemoval handles deletion
}
return $this;
}
}Các phương thức addComment() và removeComment() đảm bảo tính nhất quán hai chiều. Thiếu dòng $comment->setArticle($this), quan hệ sẽ không được lưu đúng.
Quên đồng bộ cả hai phía của một quan hệ hai chiều là lỗi phổ biến nhất. Luôn chỉnh sửa phía sở hữu để bảo đảm việc lưu trữ.
Quan hệ ManyToMany: liên kết đa phía
Quan hệ ManyToMany kết nối nhiều thực thể ở cả hai phía. Doctrine tự tạo bảng liên kết. Một phía cần được chỉ định là phía sở hữu thông qua thuộc tính inversedBy.
// An article can have multiple tags
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
// ... other properties
// ManyToMany: owning side (inversedBy)
// Join table: article_tag (auto-generated)
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
#[ORM\JoinTable(name: 'article_tag')] // Explicit table name
private Collection $tags;
public function __construct()
{
$this->tags = new ArrayCollection();
}
/**
* @return Collection<int, Tag>
*/
public function getTags(): Collection
{
return $this->tags;
}
public function addTag(Tag $tag): self
{
if (!$this->tags->contains($tag)) {
$this->tags->add($tag);
// Owning side: no need to sync other side for persistence
// but recommended for in-memory consistency
$tag->addArticle($this);
}
return $this;
}
public function removeTag(Tag $tag): self
{
if ($this->tags->removeElement($tag)) {
$tag->removeArticle($this);
}
return $this;
}
}// A tag can belong to multiple articles
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TagRepository::class)]
class Tag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50, unique: true)]
private string $name;
// Inverse side: mappedBy points to the owner
#[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
private Collection $articles;
public function __construct()
{
$this->articles = new ArrayCollection();
}
/**
* @return Collection<int, Article>
*/
public function getArticles(): Collection
{
return $this->articles;
}
public function addArticle(Article $article): self
{
if (!$this->articles->contains($article)) {
$this->articles->add($article);
}
return $this;
}
public function removeArticle(Article $article): self
{
$this->articles->removeElement($article);
return $this;
}
}Đối với quan hệ ManyToMany có dữ liệu phụ trợ (ngày liên kết, thứ tự, v.v.), nên chuyển thành hai quan hệ ManyToOne trỏ đến một thực thể trung gian.
Sẵn sàng chinh phục phỏng vấn Symfony?
Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.
Chiến lược tải và hiệu năng
Việc tải các quan hệ là điểm trọng yếu về hiệu năng trong Doctrine. Có ba chiến lược: LAZY (mặc định), EAGER và EXTRA_LAZY.
Lazy Loading: tải khi cần
Lazy loading chỉ lấy quan hệ khi truy cập lần đầu. Chiến lược mặc định này tránh các truy vấn không cần thiết, nhưng có thể gây ra vấn đề N+1.
// Demonstrating the N+1 problem
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);
}
// N+1 PROBLEM: one query per article for comments
public function findAllWithLazyComments(): array
{
// Query 1: SELECT * FROM article
$articles = $this->findAll();
// In view or service:
// foreach ($articles as $article) {
// $article->getComments(); // Query N: SELECT * FROM comment WHERE article_id = ?
// }
return $articles;
}
}Với 100 bài viết, đoạn mã này sinh ra 101 truy vấn SQL: một truy vấn cho danh sách bài viết và một truy vấn cho mỗi bài để tải bình luận.
Eager loading bằng join
Eager loading giải quyết vấn đề N+1 bằng cách lấy quan hệ trong cùng một truy vấn thông qua join.
// Optimized loading with joins
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);
}
// SOLUTION: explicit join with fetch join
public function findAllWithComments(): array
{
return $this->createQueryBuilder('a')
// LEFT JOIN loads articles even without comments
->leftJoin('a.comments', 'c')
// addSelect includes comments in the result
->addSelect('c')
// Sort by comment creation date
->orderBy('c.createdAt', 'DESC')
->getQuery()
->getResult();
// Single SQL query with JOIN
}
// Loading multiple relationships
public function findAllWithCommentsAndTags(): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.comments', 'c')
->addSelect('c')
->leftJoin('a.tags', 't')
->addSelect('t')
->leftJoin('c.author', 'ca') // Comment author join
->addSelect('ca')
->getQuery()
->getResult();
}
}Việc dùng addSelect() sau mỗi leftJoin() là điều bắt buộc. Thiếu addSelect(), Doctrine sẽ thực hiện join nhưng không tải các thực thể liên quan.
Extra Lazy: tối ưu các tập hợp lớn
Chiến lược EXTRA_LAZY tối ưu các thao tác trên tập hợp lớn mà không tải toàn bộ phần tử.
// Category with potentially thousands of products
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private string $name;
// EXTRA_LAZY: optimizes count(), contains(), slice()
#[ORM\OneToMany(
targetEntity: Product::class,
mappedBy: 'category',
fetch: 'EXTRA_LAZY' // Does not load all products
)]
private Collection $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
// count() executes SELECT COUNT(*) instead of loading collection
public function getProductCount(): int
{
return $this->products->count();
// SQL: SELECT COUNT(*) FROM product WHERE category_id = ?
}
// contains() checks existence without loading everything
public function hasProduct(Product $product): bool
{
return $this->products->contains($product);
// SQL: SELECT 1 FROM product WHERE id = ? AND category_id = ?
}
// slice() loads only a portion
public function getRecentProducts(int $limit = 5): array
{
return $this->products->slice(0, $limit);
// SQL: SELECT * FROM product WHERE category_id = ? LIMIT 5
}
}EXTRA_LAZY tránh việc tải hàng nghìn thực thể chỉ để kiểm tra hoặc đếm đơn giản.
Nên áp dụng EXTRA_LAZY cho các tập hợp có khả năng lớn (trên 100 phần tử) khi các thao tác count(), contains() hoặc slice() được gọi thường xuyên.
Cascade và quản lý vòng đời
Các tùy chọn cascade tự động lan truyền thao tác sang các thực thể liên quan. Ba tùy chọn chính: persist, remove và orphanRemoval.
Cascade Persist: lưu trữ tự động
Cascade persist tự động lưu các thực thể liên quan mới khi flush.
// Order with cascaded order lines
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: '`order`')] // order is a SQL reserved word
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 50)]
private string $reference;
// cascade persist: OrderLines are persisted with Order
#[ORM\OneToMany(
targetEntity: OrderLine::class,
mappedBy: 'order',
cascade: ['persist'] // Automatically persist lines
)]
private Collection $lines;
public function __construct()
{
$this->lines = new ArrayCollection();
$this->reference = 'ORD-' . uniqid();
}
public function addLine(OrderLine $line): self
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
$line->setOrder($this);
}
return $this;
}
}// Order creation with cascade persist
namespace App\Service;
use App\Entity\Order;
use App\Entity\OrderLine;
use App\Entity\Product;
use Doctrine\ORM\EntityManagerInterface;
class OrderService
{
public function __construct(
private readonly EntityManagerInterface $em
) {}
public function createOrder(array $cartItems): Order
{
$order = new Order();
foreach ($cartItems as $item) {
$line = new OrderLine();
$line->setProduct($item['product']);
$line->setQuantity($item['quantity']);
$line->setUnitPrice($item['product']->getPrice());
// addLine synchronizes the relationship
$order->addLine($line);
}
// Only Order is explicitly persisted
// OrderLines are persisted automatically (cascade)
$this->em->persist($order);
$this->em->flush();
return $order;
}
}Cascade Remove và OrphanRemoval
Cascade remove xóa các thực thể liên quan. OrphanRemoval đi xa hơn bằng cách xóa luôn các thực thể bị tách khỏi tập hợp.
// Blog post with images
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BlogPostRepository::class)]
class BlogPost
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\OneToMany(
targetEntity: Image::class,
mappedBy: 'blogPost',
cascade: ['persist', 'remove'], // Delete images with post
orphanRemoval: true // Also delete detached images
)]
private Collection $images;
public function __construct()
{
$this->images = new ArrayCollection();
}
public function removeImage(Image $image): self
{
if ($this->images->removeElement($image)) {
// orphanRemoval: image will be deleted on flush
// Without orphanRemoval: image would remain in DB without blogPost
}
return $this;
}
public function clearImages(): self
{
// Clear collection → all images will be deleted
$this->images->clear();
return $this;
}
}Khác biệt cốt lõi: cascade: ['remove'] chỉ xóa thực thể liên quan khi thực thể cha bị xóa. orphanRemoval: true xóa cả các thực thể bị gỡ khỏi tập hợp.
Truy vấn DQL nâng cao cho quan hệ
DQL (Doctrine Query Language) đem lại sự linh hoạt tối đa cho các truy vấn liên quan đến quan hệ phức tạp.
Lọc theo quan hệ
// Advanced queries on relationships
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);
}
// Articles with at least one comment
public function findWithComments(): array
{
return $this->createQueryBuilder('a')
->innerJoin('a.comments', 'c') // INNER JOIN excludes articles without comments
->addSelect('c')
->groupBy('a.id')
->getQuery()
->getResult();
}
// Articles by tag with comment count
public function findByTagWithCommentCount(string $tagName): array
{
return $this->createQueryBuilder('a')
->select('a', 'COUNT(c.id) as commentCount')
->leftJoin('a.comments', 'c')
->innerJoin('a.tags', 't')
->where('t.name = :tagName')
->setParameter('tagName', $tagName)
->groupBy('a.id')
->orderBy('commentCount', 'DESC')
->getQuery()
->getResult();
}
// Recent articles with comment authors
public function findRecentWithAuthors(\DateTimeInterface $since): array
{
return $this->createQueryBuilder('a')
->leftJoin('a.comments', 'c')
->addSelect('c')
->leftJoin('c.author', 'u') // Join on comment author
->addSelect('u')
->where('a.publishedAt > :since')
->setParameter('since', $since)
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
}Truy vấn con và hàm tổng hợp
// Complex queries with subqueries
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
// Most active users (by article count)
public function findMostActiveAuthors(int $limit = 10): array
{
return $this->createQueryBuilder('u')
->select('u', 'COUNT(a.id) as articleCount')
->leftJoin('u.articles', 'a')
->where('a.status = :published')
->setParameter('published', 'published')
->groupBy('u.id')
->having('COUNT(a.id) > 0') // HAVING to filter after GROUP BY
->orderBy('articleCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
// Users with articles having more than 10 comments
public function findAuthorsWithPopularArticles(): array
{
// DQL subquery
$em = $this->getEntityManager();
$subQuery = $em->createQueryBuilder()
->select('IDENTITY(a2.author)')
->from('App\Entity\Article', 'a2')
->leftJoin('a2.comments', 'c2')
->groupBy('a2.id')
->having('COUNT(c2.id) > 10')
->getDQL();
return $this->createQueryBuilder('u')
->where('u.id IN (' . $subQuery . ')')
->getQuery()
->getResult();
}
}Bắt đầu luyện tập!
Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.
Thực hành tốt nhất và mẫu nâng cao
Khởi tạo tập hợp đúng cách
Các tập hợp luôn cần được khởi tạo trong constructor để tránh lỗi kiểu dữ liệu.
// Proper relationship initialization
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuthorRepository::class)]
class Author
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'author')]
private Collection $books;
#[ORM\ManyToMany(targetEntity: Genre::class)]
private Collection $favoriteGenres;
// ALWAYS initialize in constructor
public function __construct()
{
$this->books = new ArrayCollection();
$this->favoriteGenres = new ArrayCollection();
}
// Utility method to check if collection is loaded
public function areBooksLoaded(): bool
{
return $this->books->isInitialized();
}
}Tránh tham chiếu vòng
Quan hệ hai chiều có thể gây vòng lặp vô hạn khi tuần tự hóa.
// Handling circular references
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
#[ORM\Entity(repositoryClass: DepartmentRepository::class)]
class Department
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['department:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Groups(['department:read', 'employee:read'])]
private string $name;
// MaxDepth limits serialization depth
#[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'department')]
#[Groups(['department:read'])]
#[MaxDepth(1)] // Does not serialize employee relations
private Collection $employees;
public function __construct()
{
$this->employees = new ArrayCollection();
}
}Mẫu Repository với tiêu chí động
// Flexible search criteria
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
// Search with optional filters
public function findByCriteria(array $criteria): array
{
$qb = $this->createQueryBuilder('p')
->leftJoin('p.category', 'c')
->addSelect('c')
->leftJoin('p.tags', 't')
->addSelect('t');
// Conditional filters
if (isset($criteria['category'])) {
$qb->andWhere('c.slug = :category')
->setParameter('category', $criteria['category']);
}
if (isset($criteria['minPrice'])) {
$qb->andWhere('p.price >= :minPrice')
->setParameter('minPrice', $criteria['minPrice']);
}
if (isset($criteria['maxPrice'])) {
$qb->andWhere('p.price <= :maxPrice')
->setParameter('maxPrice', $criteria['maxPrice']);
}
if (isset($criteria['tags']) && is_array($criteria['tags'])) {
$qb->andWhere('t.name IN (:tags)')
->setParameter('tags', $criteria['tags']);
}
if (isset($criteria['inStock']) && $criteria['inStock']) {
$qb->andWhere('p.stock > 0');
}
return $qb->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
}Kết luận
Làm chủ các quan hệ Doctrine ORM dựa trên một số nguyên tắc cơ bản:
✅ Phía sở hữu vs phía nghịch đảo: luôn chỉnh sửa phía sở hữu để bảo đảm việc lưu trữ
✅ Đồng bộ hai chiều: phương thức add/remove phải đồng bộ cả hai phía
✅ Fetch join: dùng addSelect() sau mỗi join để tránh vấn đề N+1
✅ EXTRA_LAZY: bật trên các tập hợp lớn để tối ưu count() và contains()
✅ Cascade thận trọng: persist thường hữu ích; remove và orphanRemoval phụ thuộc vào ngữ cảnh nghiệp vụ
✅ Khởi tạo tập hợp: luôn trong constructor với ArrayCollection
Những mẫu này tạo nền tảng cho một ứng dụng Symfony hiệu năng cao. Bước tiếp theo là làm chủ các chỉ mục và truy vấn native cho những trường hợp cần hiệu năng cực hạn.
Bắt đầu luyện tập!
Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.
Thẻ
Chia sẻ
Bài viết liên quan

Câu hỏi phỏng vấn Symfony: Top 25 năm 2026
25 câu hỏi phỏng vấn Symfony được hỏi nhiều nhất. Kiến trúc, Doctrine ORM, service, bảo mật, form và test với câu trả lời chi tiết và ví dụ code.

Symfony Live Components và UX 3.0: Ứng Dụng Phản Hồi Không Cần JavaScript Năm 2026
Symfony Live Components xây dựng giao diện phản hồi bằng PHP và Twig mà không cần JavaScript. Hướng dẫn chi tiết về LiveProp, LiveAction, form và deferred loading.

Symfony 7: API Platform va Cac Thuc Hanh Tot Nhat
Huong dan day du ve API Platform 4 voi Symfony 7. Tu State Processors, State Providers den bao mat va kiem thu — tat ca thuc hanh tot nhat cho REST API san xuat.