Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Relationships: ManyToMany

Relationships: ManyToMany

~16 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026

A user can be a member of SEVERAL projects, a project has SEVERAL members – a classic ManyToMany relationship, which needs its OWN, invisible join table in the database.

A preview: a minimal User entity

The COMPLETE User entity with security interfaces only comes in chapter 26 – for this chapter, a minimal skeleton is enough:

src/Entity/User.php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    private string $name = '';

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }
}

The ManyToMany relationship in Project

src/Entity/Project.php
class Project
{
    // ... existing fields ...

    /**
     * @var Collection<int, User>
     */
    #[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'projects')]
    private Collection $members;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
        $this->tasks = new ArrayCollection();
        $this->members = new ArrayCollection();
    }

    /**
     * @return Collection<int, User>
     */
    public function getMembers(): Collection
    {
        return $this->members;
    }

    public function addMember(User $user): static
    {
        if (!$this->members->contains($user)) {
            $this->members->add($user);
        }

        return $this;
    }

    public function removeMember(User $user): static
    {
        $this->members->removeElement($user);

        return $this;
    }
}

The inverse side in User

src/Entity/User.php
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

class User
{
    // ... existing fields ...

    /**
     * @var Collection<int, Project>
     */
    #[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'members')]
    private Collection $projects;

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

    /**
     * @return Collection<int, Project>
     */
    public function getProjects(): Collection
    {
        return $this->projects;
    }
}

With ManyToMany, there's NO #[ORM\JoinColumn] on the owning side – instead, Doctrine creates its OWN join table, named by default after BOTH entity names: project_user.

Migration for the ManyToMany relationship

php bin/console make:migration
// Generated SQL (excerpt):
CREATE TABLE project_user (
    project_id INT NOT NULL,
    user_id INT NOT NULL,
    PRIMARY KEY(project_id, user_id)
);
ALTER TABLE project_user ADD CONSTRAINT FK_... FOREIGN KEY (project_id) REFERENCES project (id);
ALTER TABLE project_user ADD CONSTRAINT FK_... FOREIGN KEY (user_id) REFERENCES "user" (id);

The COMPOSITE primary key (project_id, user_id) ensures the same membership can NOT be stored twice – a database-level guarantee that goes beyond pure application logic.

Adding members

$project->addMember($user);
$entityManager->flush();

// From the OTHER side, this doesn't sync automatically -
// for ManyToMany without a dedicated join entity, this is usually harmless in practice,
// since you'd typically add ONLY through the owning side (here: Project).

Achtung: Unlike OneToMany/ManyToOne in chapter 22, addMember() here does NOT automatically sync the other side ($user->getProjects() would NOT update without a fresh database reload). Harmless in practice as long as you CONSISTENTLY add through ONE side – if needed, the sync can be added manually, analogous to chapter 22.

Which side is the owning side for ManyToMany?

For ManyToMany, the choice is ARBITRARY (unlike ManyToOne, where the side with the foreign key is necessarily the owning side) – rule of thumb: the side you MORE OFTEN add members through in practice becomes the owning side (inversedBy), the other side the inverse side (mappedBy). For our task manager: projects typically manage their own members, so Project is the owning side here.

With that, the four core entities from chapter 5 (User, Project, Task, Comment follows in chapter 24) are fully connected via real relationships – chapter 24 uses this structure for more advanced queries with the query builder.