Doctrine ORM:Symfonyにおけるリレーションのマスター

SymfonyにおけるDoctrine ORMリレーションの完全ガイド。OneToMany、ManyToMany、ロード戦略、パフォーマンス最適化を実例とともに解説します。

SymfonyにおけるDoctrine ORMのリレーション - 完全ガイド

エンティティ間のリレーションは、Doctrine ORMを使用するすべてのSymfonyアプリケーションの根幹を成します。リレーションの種類、ロード戦略、パフォーマンスの落とし穴を確かに理解することで、堅牢で高性能なアプリケーションを構築できます。本ガイドでは、Doctrineのリレーションを効果的に管理するための重要なパターンを取り上げます。

基本原則

Doctrineは所有側と非所有側という概念を使用します。この区別を理解することで、リレーションの永続化に関する多くのバグを防げます。

Doctrineのリレーション種別

Doctrineは、エンティティ間に4種類のリレーションを提供します: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リレーションの非所有側を表します。「1」のエンティティから「多」のエンティティへの参照を可能にします。このリレーションは決して所有側にはならず、外部キーも保持しません。

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リレーション(関連付けの日時、順序など)では、中間エンティティを指す2つのManyToOneリレーションへ変換することをお勧めします。

Symfonyの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

ロード戦略とパフォーマンス

リレーションのロードはDoctrineにおけるパフォーマンスの要です。LAZY(デフォルト)、EAGER、EXTRA_LAZYの3つの戦略があります。

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件の記事に対し、このコードは101回のSQLクエリを生成します。記事用に1回、コメントをロードするために記事ごとに1回ずつです。

joinによるEager Loading

Eagerロードは、joinを用いて同一クエリでリレーションを取得することで、N+1問題を解消します。

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();
    }
}

leftJoin()の後にaddSelect()を使用することは不可欠です。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は、count()、contains()、slice()などの操作が頻繁な、要素数が大きくなり得る(100件以上)コレクションに適用すべきです。

カスケードとライフサイクル管理

カスケードオプションは、関連エンティティへの操作伝播を自動化します。主要なオプションは3つあります: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();
    }
}

サブクエリと集計

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();
    }
}

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

ベストプラクティスと高度なパターン

コレクションの正しい初期化

型エラーを避けるため、コレクションは必ずコンストラクタで初期化します。

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();
    }
}

循環参照の回避

双方向リレーションはシリアライズ時に無限ループを生むことがあります。

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();
    }
}

動的条件によるリポジトリパターン

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リレーションのマスターは、いくつかの基本原則の上に成り立ちます。

所有側 vs 非所有側:永続化を保証するため、常に所有側を変更します

双方向の同期:add/removeメソッドは双方を同期させる必要があります

Fetch join:N+1問題を避けるため、各joinの後にaddSelect()を使います

EXTRA_LAZY:大規模コレクションで有効化し、count()やcontains()を最適化します

慎重なカスケード:persistはしばしば有用ですが、removeとorphanRemovalはビジネスコンテキスト次第です

コレクションの初期化:常にコンストラクタでArrayCollectionを使います

これらのパターンが高性能なSymfonyアプリケーションの基盤を形成します。次のステップは、極限の性能要件に対応するためにインデックスとネイティブクエリを使いこなすことです。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#doctrine
#symfony
#orm
#php
#database

共有

関連記事