Many-to-Many

How a join table stores a many-to-many link, which side Doctrine writes from, and when to replace it with an entity.

Syntax#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')] #[ORM\JoinTable(name: 'article_tag')]

Some links have many on both ends. An article carries several tags, and a tag is on several articles. Neither table can hold the key, because a column holds one value. The link lives in a third table, the join table, whose rows are nothing but a pair of foreign keys.

Who owns the join table

The side mapped with #[ORM\ManyToMany] and inversedBy is the owning side: Doctrine writes the join table from its collection. The other side adds mappedBy and is read-only as far as writes go. Changing the inverse collection changes nothing in the database.

#[ORM\JoinTable] is optional. Left out, the table and both columns are named from the two entities, giving article_tag with article_id and tag_id under Symfony's default naming strategy. Write it out when you need a specific name, or when the relation is self-referencing and both columns would otherwise collide.

Adding and removing

You work with the collection, not SQL. add() and removeElement() on the owning side's collection become INSERT and DELETE statements against the join table on the next flush. Neither entity's own table is touched.

Guard add() with contains(). The join table's two columns form its primary key, so inserting the same pair twice is a constraint violation rather than a silent no-op.

When it is not enough

A join table row has no identity and nowhere to put extra data. The first time you need to store when a tag was added, the relation becomes an entity of its own with two many-to-one associations.

Example

Example · php
<?php
// src/Entity/Article.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // Owning side: this collection is what Doctrine writes.
    // Shorthand - the join table and its two columns take default names.
    /** @var Collection<int, Tag> */
    #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
    // Longhand of the same mapping, every name spelled out:
    // #[ORM\JoinTable(name: 'article_tag')]
    // #[ORM\JoinColumn(name: 'article_id', referencedColumnName: 'id')]
    // #[ORM\InverseJoinColumn(name: 'tag_id', referencedColumnName: 'id')]
    private Collection $tags;

    public function __construct()
    {
        $this->tags = new ArrayCollection();
    }

    public function addTag(Tag $tag): void
    {
        // The pair is the join table's primary key, so a duplicate add
        // would fail on flush rather than be ignored.
        if (!$this->tags->contains($tag)) {
            $this->tags->add($tag);
        }
    }

    public function removeTag(Tag $tag): void
    {
        $this->tags->removeElement($tag);
    }
}

When to use it

  • Tag articles, where tags are shared across the whole site.
  • Put users into groups and read the group's members back.
  • Record which optional extras a booking includes.
  • Replace a join table with an entity once you need to store when the link was made.

More examples

The inverse side

Map the inverse side only when you actually read it, for example to list the articles under a tag. It costs nothing in the schema, but it is a reading convenience, not a writing one: to attach a tag you call addTag() on the article. If you never need tag-to-articles navigation, leave this property out and the relation stays unidirectional.

Example · php
<?php
// src/Entity/Tag.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Tag
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 50, unique: true)]
    private string $name = '';

    // Inverse side: mappedBy names the property on Article that owns
    // the join table. Nothing here is written on flush.
    /** @var Collection<int, Article> */
    #[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
    private Collection $articles;

    public function __construct(string $name)
    {
        $this->name = $name;
        $this->articles = new ArrayCollection();
    }

    public function getArticles(): Collection
    {
        return $this->articles;
    }
}

Attaching a tag in a service

Reuse an existing tag when there is one, otherwise persist a new one. The flush writes at most one INSERT into tag and one INSERT into article_tag. Note that the new Tag has to be persisted explicitly here: the association carries no cascade, so an unsaved object reachable from the collection would raise an error on flush instead of being saved.

Example · php
<?php
// src/Service/ArticleTagger.php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Article;
use App\Entity\Tag;
use App\Repository\TagRepository;
use Doctrine\ORM\EntityManagerInterface;

final class ArticleTagger
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly TagRepository $tags,
    ) {
    }

    public function tag(Article $article, string $name): void
    {
        $tag = $this->tags->findOneBy(['name' => $name]);

        if (null === $tag) {
            $tag = new Tag($name);
            $this->entityManager->persist($tag);
        }

        // Owning side, so this is the call that produces the join row.
        $article->addTag($tag);

        $this->entityManager->flush();
    }
}

When the link needs its own columns

The same two foreign keys, now in a table you control, with a unique constraint keeping the pair from repeating. The timestamp is passed in rather than created inside the constructor, so the entity stays testable. Reach for this as soon as the relation itself has attributes: who added it, when, in what order, with what weight. Converting later means a migration, so decide before the data exists if you can.

Example · php
<?php
// src/Entity/ArticleTag.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\UniqueConstraint(name: 'uniq_article_tag', columns: ['article_id', 'tag_id'])]
class ArticleTag
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    private Article $article;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    private Tag $tag;

    // The column that a plain many-to-many has nowhere to put.
    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $addedAt;

    public function __construct(Article $article, Tag $tag, \DateTimeImmutable $addedAt)
    {
        $this->article = $article;
        $this->tag = $tag;
        $this->addedAt = $addedAt;
    }
}

Discussion

  • Be the first to comment on this lesson.