# Doctrine ORM:Symfonyにおけるリレーションのマスター > SymfonyにおけるDoctrine ORMリレーションの完全ガイド。OneToMany、ManyToMany、ロード戦略、パフォーマンス最適化を実例とともに解説します。 - Published: 2026-02-17 - Updated: 2026-04-28 - Author: SharpSkill - Tags: doctrine, symfony, orm, php, database - Reading time: 18 min --- エンティティ間のリレーションは、Doctrine ORMを使用するすべてのSymfonyアプリケーションの根幹を成します。リレーションの種類、ロード戦略、パフォーマンスの落とし穴を確かに理解することで、堅牢で高性能なアプリケーションを構築できます。本ガイドでは、Doctrineのリレーションを効果的に管理するための重要なパターンを取り上げます。 > **基本原則** > > Doctrineは所有側と非所有側という概念を使用します。この区別を理解することで、リレーションの永続化に関する多くのバグを防げます。 ## Doctrineのリレーション種別 Doctrineは、エンティティ間に4種類のリレーションを提供します: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リレーションの非所有側を表します。「1」のエンティティから「多」のエンティティへの参照を可能にします。このリレーションは決して所有側にはならず、外部キーも保持しません。 ```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リレーション(関連付けの日時、順序など)では、中間エンティティを指す2つのManyToOneリレーションへ変換することをお勧めします。 ## ロード戦略とパフォーマンス リレーションのロードはDoctrineにおけるパフォーマンスの要です。LAZY(デフォルト)、EAGER、EXTRA_LAZYの3つの戦略があります。 ### 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件の記事に対し、このコードは101回のSQLクエリを生成します。記事用に1回、コメントをロードするために記事ごとに1回ずつです。 ### joinによるEager Loading Eagerロードは、joinを用いて同一クエリでリレーションを取得することで、N+1問題を解消します。 ```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(); } } ``` 各`leftJoin()`の後に`addSelect()`を使用することは不可欠です。`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は、count()、contains()、slice()などの操作が頻繁な、要素数が大きくなり得る(100件以上)コレクションに適用すべきです。 ## カスケードとライフサイクル管理 カスケードオプションは、関連エンティティへの操作伝播を自動化します。主要なオプションは3つあります: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(); } } ``` ### サブクエリと集計 ```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(); } } ``` ## ベストプラクティスと高度なパターン ### コレクションの正しい初期化 型エラーを避けるため、コレクションは必ずコンストラクタで初期化します。 ```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(); } } ``` ### 循環参照の回避 双方向リレーションはシリアライズ時に無限ループを生むことがあります。 ```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(); } } ``` ### 動的条件によるリポジトリパターン ```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リレーションのマスターは、いくつかの基本原則の上に成り立ちます。 ✅ **所有側 vs 非所有側**:永続化を保証するため、常に所有側を変更します ✅ **双方向の同期**:add/removeメソッドは双方を同期させる必要があります ✅ **Fetch join**:N+1問題を避けるため、各joinの後に`addSelect()`を使います ✅ **EXTRA_LAZY**:大規模コレクションで有効化し、count()やcontains()を最適化します ✅ **慎重なカスケード**:persistはしばしば有用ですが、removeとorphanRemovalはビジネスコンテキスト次第です ✅ **コレクションの初期化**:常にコンストラクタでArrayCollectionを使います これらのパターンが高性能なSymfonyアプリケーションの基盤を形成します。次のステップは、極限の性能要件に対応するためにインデックスとネイティブクエリを使いこなすことです。 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/symfony/doctrine-orm-mastering-relationships