Doctrine ORM: เชี่ยวชาญความสัมพันธ์ใน Symfony

คู่มือฉบับสมบูรณ์เกี่ยวกับความสัมพันธ์ Doctrine ORM ใน Symfony OneToMany, ManyToMany, กลยุทธ์การโหลดและการเพิ่มประสิทธิภาพพร้อมตัวอย่างจริง

ความสัมพันธ์ Doctrine ORM ใน Symfony - คู่มือฉบับสมบูรณ์

ความสัมพันธ์ระหว่างเอนทิตีคือกระดูกสันหลังของแอปพลิเคชัน Symfony ทุกตัวที่ใช้ Doctrine ORM ความเข้าใจที่มั่นคงเกี่ยวกับประเภทความสัมพันธ์ กลยุทธ์การโหลด และกับดักด้านประสิทธิภาพช่วยให้สร้างแอปพลิเคชันที่แข็งแรงและรวดเร็ว คู่มือนี้ครอบคลุมแพตเทิร์นสำคัญสำหรับการจัดการความสัมพันธ์ Doctrine อย่างมีประสิทธิผล

หลักการสำคัญ

Doctrine ใช้แนวคิดของฝั่งเจ้าของและฝั่งผกผัน การเข้าใจความแตกต่างนี้ช่วยป้องกันบั๊กจำนวนมากที่เกี่ยวข้องกับการบันทึกความสัมพันธ์

ประเภทความสัมพันธ์ใน Doctrine

Doctrine นำเสนอความสัมพันธ์ระหว่างเอนทิตีสี่แบบ ได้แก่ OneToOne, OneToMany, ManyToOne และ ManyToMany แต่ละประเภทตอบโจทย์ความต้องการทางธุรกิจที่ต่างกันและมีคุณสมบัติด้านประสิทธิภาพเฉพาะตัว

ความสัมพันธ์ ManyToOne: กรณีที่พบบ่อยที่สุด

ความสัมพันธ์ ManyToOne คือลิงก์ฐานข้อมูลที่พบบ่อยที่สุด เอนทิตีหลายตัวฝั่งหนึ่งสัมพันธ์กับเอนทิตีตัวเดียวอีกฝั่ง ความสัมพันธ์นี้เป็นฝั่งเจ้าของของการเชื่อมโยงเสมอ

src/Entity/Comment.phpphp
// 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 ช่วยให้ไล่จากเอนทิตี «หนึ่ง» ไปยังเอนทิตี «หลาย» ได้ ความสัมพันธ์นี้ไม่เคยเป็นฝั่งเจ้าของและไม่มีคีย์ต่างประเทศ

src/Entity/Article.phpphp
// 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;
    }
}

เมธอด addComment() และ removeComment() รับประกันความสอดคล้องสองทาง หากขาดบรรทัด $comment->setArticle($this) ความสัมพันธ์จะไม่ถูกบันทึกอย่างถูกต้อง

กับดักที่พบบ่อย

การลืมซิงค์ทั้งสองฝั่งของความสัมพันธ์สองทางคือข้อผิดพลาดที่พบบ่อยที่สุด ควรแก้ไขฝั่งเจ้าของเสมอเพื่อรับประกันการบันทึก

ความสัมพันธ์ ManyToMany: การเชื่อมโยงหลายต่อหลาย

ความสัมพันธ์ ManyToMany เชื่อมหลายเอนทิตีในทั้งสองฝั่ง Doctrine จะสร้างตารางเชื่อมให้อัตโนมัติ ต้องกำหนดฝั่งหนึ่งให้เป็นเจ้าของผ่านแอตทริบิวต์ inversedBy

src/Entity/Article.phpphp
// 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;
    }
}
src/Entity/Tag.phpphp
// 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;
    }
}

สำหรับความสัมพันธ์ ManyToMany ที่ต้องเก็บข้อมูลเพิ่มเติม (วันที่เชื่อมโยง ลำดับ ฯลฯ) ควรเปลี่ยนเป็นความสัมพันธ์ ManyToOne สองชุดที่ชี้ไปยังเอนทิตีกลาง

พร้อมที่จะพิชิตการสัมภาษณ์ Symfony แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

กลยุทธ์การโหลดและประสิทธิภาพ

การโหลดความสัมพันธ์คือจุดสำคัญด้านประสิทธิภาพใน Doctrine มีสามกลยุทธ์ ได้แก่ LAZY (ค่าเริ่มต้น), EAGER และ EXTRA_LAZY

Lazy Loading: โหลดเมื่อต้องใช้

การโหลดแบบขี้เกียจดึงความสัมพันธ์เฉพาะเมื่อมีการเข้าถึงครั้งแรก กลยุทธ์ค่าเริ่มต้นนี้ช่วยหลีกเลี่ยงคิวรีที่ไม่จำเป็น แต่อาจก่อให้เกิดปัญหา N+1

src/Repository/ArticleRepository.phpphp
// 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

src/Repository/ArticleRepository.phpphp
// 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 เพิ่มประสิทธิภาพการดำเนินการบนคอลเลกชันขนาดใหญ่โดยไม่ต้องโหลดทุกองค์ประกอบ

src/Entity/Category.phpphp
// 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

src/Entity/Order.phpphp
// 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;
    }
}
src/Service/OrderService.phpphp
// 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 ก้าวไกลกว่านั้นโดยลบเอนทิตีที่ถูกแยกออกจากคอลเลกชันด้วย

src/Entity/BlogPost.phpphp
// 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) มอบความยืดหยุ่นสูงสุดสำหรับคิวรีที่เกี่ยวข้องกับความสัมพันธ์ที่ซับซ้อน

การกรองตามความสัมพันธ์

src/Repository/ArticleRepository.phpphp
// 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 และฟังก์ชันรวม

src/Repository/UserRepository.phpphp
// 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 เสมอเพื่อหลีกเลี่ยงข้อผิดพลาดด้านชนิดข้อมูล

src/Entity/Author.phpphp
// 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

src/Entity/Department.phpphp
// 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 พร้อมเงื่อนไขแบบไดนามิก

src/Repository/ProductRepository.phpphp
// 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 สำหรับกรณีที่ต้องการประสิทธิภาพระดับสูงสุด

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#doctrine
#symfony
#orm
#php
#database

แชร์

บทความที่เกี่ยวข้อง