# Doctrine ORM: เชี่ยวชาญความสัมพันธ์ใน Symfony > คู่มือฉบับสมบูรณ์เกี่ยวกับความสัมพันธ์ Doctrine ORM ใน Symfony OneToMany, ManyToMany, กลยุทธ์การโหลดและการเพิ่มประสิทธิภาพพร้อมตัวอย่างจริง - Published: 2026-02-17 - Updated: 2026-04-28 - Author: SharpSkill - Tags: doctrine, symfony, orm, php, database - Reading time: 18 min --- ความสัมพันธ์ระหว่างเอนทิตีคือกระดูกสันหลังของแอปพลิเคชัน Symfony ทุกตัวที่ใช้ Doctrine ORM ความเข้าใจที่มั่นคงเกี่ยวกับประเภทความสัมพันธ์ กลยุทธ์การโหลด และกับดักด้านประสิทธิภาพช่วยให้สร้างแอปพลิเคชันที่แข็งแรงและรวดเร็ว คู่มือนี้ครอบคลุมแพตเทิร์นสำคัญสำหรับการจัดการความสัมพันธ์ Doctrine อย่างมีประสิทธิผล > **หลักการสำคัญ** > > Doctrine ใช้แนวคิดของฝั่งเจ้าของและฝั่งผกผัน การเข้าใจความแตกต่างนี้ช่วยป้องกันบั๊กจำนวนมากที่เกี่ยวข้องกับการบันทึกความสัมพันธ์ ## ประเภทความสัมพันธ์ใน Doctrine Doctrine นำเสนอความสัมพันธ์ระหว่างเอนทิตีสี่แบบ ได้แก่ OneToOne, OneToMany, ManyToOne และ ManyToMany แต่ละประเภทตอบโจทย์ความต้องการทางธุรกิจที่ต่างกันและมีคุณสมบัติด้านประสิทธิภาพเฉพาะตัว ### ความสัมพันธ์ ManyToOne: กรณีที่พบบ่อยที่สุด ความสัมพันธ์ ManyToOne คือลิงก์ฐานข้อมูลที่พบบ่อยที่สุด เอนทิตีหลายตัวฝั่งหนึ่งสัมพันธ์กับเอนทิตีตัวเดียวอีกฝั่ง ความสัมพันธ์นี้เป็นฝั่งเจ้าของของการเชื่อมโยงเสมอ ```php // src/Entity/Comment.php // 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; } } ``` เอนทิตี `Comment` เก็บคีย์ต่างประเทศ `article_id` Doctrine จัดการการบันทึกโดยอัตโนมัติ การเรียก `$comment->setArticle($article)` จะสร้างลิงก์ในฐานข้อมูล ### ความสัมพันธ์ OneToMany: ฝั่งผกผัน ความสัมพันธ์ OneToMany คือฝั่งผกผันของ ManyToOne ช่วยให้ไล่จากเอนทิตี «หนึ่ง» ไปยังเอนทิตี «หลาย» ได้ ความสัมพันธ์นี้ไม่เคยเป็นฝั่งเจ้าของและไม่มีคีย์ต่างประเทศ ```php // src/Entity/Article.php // 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 */ 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; } } ``` เมธอด `addComment()` และ `removeComment()` รับประกันความสอดคล้องสองทาง หากขาดบรรทัด `$comment->setArticle($this)` ความสัมพันธ์จะไม่ถูกบันทึกอย่างถูกต้อง > **กับดักที่พบบ่อย** > > การลืมซิงค์ทั้งสองฝั่งของความสัมพันธ์สองทางคือข้อผิดพลาดที่พบบ่อยที่สุด ควรแก้ไขฝั่งเจ้าของเสมอเพื่อรับประกันการบันทึก ## ความสัมพันธ์ ManyToMany: การเชื่อมโยงหลายต่อหลาย ความสัมพันธ์ ManyToMany เชื่อมหลายเอนทิตีในทั้งสองฝั่ง Doctrine จะสร้างตารางเชื่อมให้อัตโนมัติ ต้องกำหนดฝั่งหนึ่งให้เป็นเจ้าของผ่านแอตทริบิวต์ `inversedBy` ```php // src/Entity/Article.php // 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 */ 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; } } ``` ```php // src/Entity/Tag.php // 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 */ 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; } } ``` สำหรับความสัมพันธ์ ManyToMany ที่ต้องเก็บข้อมูลเพิ่มเติม (วันที่เชื่อมโยง ลำดับ ฯลฯ) ควรเปลี่ยนเป็นความสัมพันธ์ ManyToOne สองชุดที่ชี้ไปยังเอนทิตีกลาง ## กลยุทธ์การโหลดและประสิทธิภาพ การโหลดความสัมพันธ์คือจุดสำคัญด้านประสิทธิภาพใน Doctrine มีสามกลยุทธ์ ได้แก่ LAZY (ค่าเริ่มต้น), EAGER และ EXTRA_LAZY ### Lazy Loading: โหลดเมื่อต้องใช้ การโหลดแบบขี้เกียจดึงความสัมพันธ์เฉพาะเมื่อมีการเข้าถึงครั้งแรก กลยุทธ์ค่าเริ่มต้นนี้ช่วยหลีกเลี่ยงคิวรีที่ไม่จำเป็น แต่อาจก่อให้เกิดปัญหา N+1 ```php // src/Repository/ArticleRepository.php // 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; } } ``` สำหรับ 100 บทความ โค้ดนี้จะสร้างคิวรี SQL 101 ครั้ง คือหนึ่งครั้งสำหรับบทความและอีกหนึ่งครั้งต่อบทความเพื่อโหลดความคิดเห็น ### Eager Loading ด้วย join Eager loading แก้ปัญหา N+1 โดยดึงความสัมพันธ์ในคิวรีเดียวกันด้วย join ```php // src/Repository/ArticleRepository.php // 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(); } } ``` การใช้ `addSelect()` หลังทุก `leftJoin()` เป็นสิ่งจำเป็น หากไม่มี `addSelect()` Doctrine จะทำ join แต่จะไม่โหลดเอนทิตีที่เกี่ยวข้อง ### Extra Lazy: เพิ่มประสิทธิภาพคอลเลกชันขนาดใหญ่ กลยุทธ์ EXTRA_LAZY เพิ่มประสิทธิภาพการดำเนินการบนคอลเลกชันขนาดใหญ่โดยไม่ต้องโหลดทุกองค์ประกอบ ```php // src/Entity/Category.php // 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 ช่วยหลีกเลี่ยงการโหลดเอนทิตีนับพันเพียงเพื่อตรวจสอบหรือนับอย่างง่าย > **เมื่อใดควรใช้ EXTRA_LAZY** > > ควรใช้ EXTRA_LAZY กับคอลเลกชันที่อาจมีขนาดใหญ่ (เกิน 100 องค์ประกอบ) ซึ่งใช้การดำเนินการ count(), contains() หรือ slice() บ่อย ## Cascade และการจัดการวงจรชีวิต ตัวเลือก cascade ทำให้การเผยแพร่ปฏิบัติการไปยังเอนทิตีที่เกี่ยวข้องเป็นอัตโนมัติ มีตัวเลือกหลักสามแบบ ได้แก่ persist, remove และ orphanRemoval ### Cascade Persist: การบันทึกอัตโนมัติ Cascade persist บันทึกเอนทิตีที่เกี่ยวข้องที่สร้างใหม่โดยอัตโนมัติเมื่อ flush ```php // src/Entity/Order.php // 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; } } ``` ```php // src/Service/OrderService.php // 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 และ OrphanRemoval Cascade remove ลบเอนทิตีที่เกี่ยวข้อง OrphanRemoval ก้าวไกลกว่านั้นโดยลบเอนทิตีที่ถูกแยกออกจากคอลเลกชันด้วย ```php // src/Entity/BlogPost.php // 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; } } ``` ความแตกต่างสำคัญ: `cascade: ['remove']` จะลบเอนทิตีที่เกี่ยวข้องเฉพาะเมื่อเอนทิตีพ่อถูกลบ ส่วน `orphanRemoval: true` ลบเอนทิตีที่ถูกนำออกจากคอลเลกชันด้วย ## คิวรี DQL ขั้นสูงสำหรับความสัมพันธ์ DQL (Doctrine Query Language) มอบความยืดหยุ่นสูงสุดสำหรับคิวรีที่เกี่ยวข้องกับความสัมพันธ์ที่ซับซ้อน ### การกรองตามความสัมพันธ์ ```php // src/Repository/ArticleRepository.php // 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(); } } ``` ### Subquery และฟังก์ชันรวม ```php // src/Repository/UserRepository.php // 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(); } } ``` ## แนวทางปฏิบัติและแพตเทิร์นขั้นสูง ### การกำหนดค่าเริ่มต้นของคอลเลกชันอย่างถูกต้อง คอลเลกชันต้องถูกกำหนดค่าเริ่มต้นใน constructor เสมอเพื่อหลีกเลี่ยงข้อผิดพลาดด้านชนิดข้อมูล ```php // src/Entity/Author.php // 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(); } } ``` ### หลีกเลี่ยงการอ้างอิงแบบวงกลม ความสัมพันธ์สองทางอาจสร้างวงวนไม่สิ้นสุดในการ serialize ```php // src/Entity/Department.php // 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(); } } ``` ### แพตเทิร์น Repository พร้อมเงื่อนไขแบบไดนามิก ```php // src/Repository/ProductRepository.php // 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(); } } ``` ## บทสรุป การเชี่ยวชาญความสัมพันธ์ Doctrine ORM ตั้งอยู่บนหลักการพื้นฐานไม่กี่ข้อ: ✅ **ฝั่งเจ้าของกับฝั่งผกผัน**: ปรับปรุงฝั่งเจ้าของเสมอเพื่อรับประกันการบันทึก ✅ **การซิงค์สองทาง**: เมธอด add/remove ต้องซิงค์ทั้งสองฝั่ง ✅ **Fetch join**: ใช้ `addSelect()` หลังทุก join เพื่อหลีกเลี่ยงปัญหา N+1 ✅ **EXTRA_LAZY**: เปิดใช้งานในคอลเลกชันขนาดใหญ่เพื่อเพิ่มประสิทธิภาพ count() และ contains() ✅ **Cascade อย่างระมัดระวัง**: persist มักจะมีประโยชน์ ส่วน remove และ orphanRemoval ขึ้นอยู่กับบริบททางธุรกิจ ✅ **การกำหนดค่าเริ่มต้นของคอลเลกชัน**: ใน constructor เสมอด้วย ArrayCollection แพตเทิร์นเหล่านี้เป็นรากฐานของแอปพลิเคชัน Symfony ที่มีประสิทธิภาพ ขั้นตอนถัดไปคือการเชี่ยวชาญดัชนีและคิวรี native สำหรับกรณีที่ต้องการประสิทธิภาพระดับสูงสุด --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/symfony/doctrine-orm-mastering-relationships