The Many To Many relation connects two entities through an intermediate pivot (junction) table. This is the standard way to model many-to-many relationships in relational databases.
Examples: Users have many tags, posts have many categories, students enroll in many courses.
Many To Many is actually two HasMany relations combined: source → pivot and pivot → target.
To define a ManyToMany relation, you need three entities: source, target, and pivot.
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Relation\ManyToMany;
#[Entity]
class User
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'string')]
private string $username;
#[ManyToMany(target: Tag::class, through: UserTag::class)]
private array $tags = [];
public function getTags(): array
{
return $this->tags;
}
public function addTag(Tag $tag): void
{
$this->tags[] = $tag;
}
public function removeTag(Tag $tag): void
{
$this->tags = array_filter(
$this->tags,
static fn(Tag $t) => $t !== $tag
);
}
}
#[Entity]
class Tag
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'string')]
private string $name;
public function __construct(string $name)
{
$this->name = $name;
}
public function getName(): string
{
return $this->name;
}
}
#[Entity]
class UserTag
{
#[Column(type: 'primary')]
private int $id;
}
| Parameter | Type | Default | Description |
|---|---|---|---|
| target | string | - | Required. Target entity role or class name |
| through | string | - | Required. Pivot entity role or class name |
| load | string | 'lazy' | Loading strategy: 'lazy' or 'eager' |
| cascade | bool | true | Automatically save related entities with source entity |
| nullable | bool | false | Whether relations can be null (affects FK constraints in pivot) |
| innerKey | string|array | null | Key column(s) in source entity. Defaults to source's primary key |
| outerKey | string|array | null | Key column(s) in target entity. Defaults to target's primary key |
| throughInnerKey | string|array | null | Foreign key column(s) in pivot referencing source. Defaults to {sourceRole}_{innerKey} |
| throughOuterKey | string|array | null | Foreign key column(s) in pivot referencing target. Defaults to {targetRole}_{outerKey} |
| where | array | [] | WHERE conditions applied to target entity when loading |
| throughWhere | array | [] | WHERE conditions applied to pivot entity when loading |
| orderBy | array | [] | Default sorting for loaded collection |
| fkCreate | bool | true | Automatically create foreign key constraints in pivot table |
| fkAction | string | 'CASCADE' | Foreign key action for both DELETE and UPDATE: 'CASCADE', 'NO ACTION', 'SET NULL' |
| fkOnDelete | string | null | Foreign key DELETE action (overrides fkAction if set): 'CASCADE', 'NO ACTION', 'SET NULL' |
| indexCreate | bool | true | Automatically create index on [throughInnerKey, throughOuterKey] |
| collection | string | null | Collection class for loaded entities. See Collections |
| inverse | Inverse | null | Configure inverse relation on target entity. See Inverse Relations |
Since Cycle ORM v2.x, all key parameters can be arrays for composite keys.
By default, the ORM generates foreign key columns in the pivot table:
#[Entity]
class User
{
#[ManyToMany(target: Tag::class, through: UserTag::class)]
private array $tags = [];
}
This creates in the UserTag pivot table:
user_id → references user.id
tag_id → references tag.id
Customize column names:
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
throughInnerKey: 'person_id',
throughOuterKey: 'label_id'
)]
private array $tags = [];
The pivot entity can be minimal (just a primary key) or contain additional data:
#[Entity]
class UserTag
{
#[Column(type: 'primary')]
private int $id;
}
#[Entity]
class UserTag
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'datetime')]
private \DateTimeInterface $assignedAt;
#[Column(type: 'integer')]
private int $priority = 0;
public function __construct()
{
$this->assignedAt = new \DateTimeImmutable();
}
public function getAssignedAt(): \DateTimeInterface
{
return $this->assignedAt;
}
public function setPriority(int $priority): void
{
$this->priority = $priority;
}
}
Related entities are automatically saved with the source (unless cascade: false):
$user = new User();
$user->setUsername("johndoe");
$tag1 = new Tag("php");
$tag2 = new Tag("database");
$tag3 = new Tag("orm");
$user->addTag($tag1);
$user->addTag($tag2);
$user->addTag($tag3);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
The save order:
User) is savedTag) are savedUserTag) are created linking source and targets$user = $orm->getRepository(User::class)
->select()
->load('tags')
->wherePK(1)
->fetchOne();
$newTag = new Tag("security");
$user->addTag($newTag);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
$user = $orm->getRepository(User::class)
->select()
->load('tags')
->wherePK(1)
->fetchOne();
$tag = $orm->getRepository(Tag::class)->findOne(['name' => 'php']);
if (!in_array($tag, $user->getTags(), true)) {
$user->addTag($tag);
}
Removing from the collection deletes the pivot record, not the target entity:
$user = $orm->getRepository(User::class)
->select()
->load('tags')
->wherePK(1)
->fetchOne();
$tagToRemove = $user->getTags()[0];
$user->removeTag($tagToRemove);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
// UserTag pivot record deleted, Tag entity remains
$user->setTags([]);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
// All UserTag pivot records deleted
Load related entities explicitly:
$user = $orm->getRepository(User::class)
->select()
->load('tags')
->wherePK(1)
->fetchOne();
foreach ($user->getTags() as $tag) {
echo $tag->getName() . "\n";
}
Configure automatic loading:
#[ManyToMany(target: Tag::class, through: UserTag::class, load: 'eager')]
private array $tags = [];
$user = $orm->getRepository(User::class)->findByPK(1);
// Tags are already loaded
print_r($user->getTags());
Pre-filter target entities when loading:
$users = $orm->getRepository(User::class)
->select()
->load('tags', ['where' => ['active' => true]])
->fetchAll();
Set default filters in the relation:
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
where: ['active' => true, 'verified' => true]
)]
private array $tags = [];
Specify sorting when loading:
$users = $orm->getRepository(User::class)
->select()
->load('tags', ['orderBy' => ['name' => 'ASC']])
->fetchAll();
Set default sorting in the relation:
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
orderBy: ['priority' => 'DESC', 'name' => 'ASC']
)]
private array $tags = [];
with() for FilteringFilter source entities based on target entity criteria:
$users = $orm->getRepository(User::class)
->select()
->distinct()
->with('tags')->where('tags.name', 'php')
->fetchAll();
Important: Always use
distinct()with ManyToMany filtering to avoid duplicate source rows.
// Automatically joins tags and user_tags tables
$users = $orm->getRepository(User::class)
->select()
->distinct()
->where('tags.name', 'php')
->where('tags.active', true)
->fetchAll();
Find users with specific tags:
$users = $orm->getRepository(User::class)
->select()
->distinct()
->where('tags.name', 'in', ['php', 'database', 'orm'])
->having('COUNT(DISTINCT tags.id)', '>=', 2)
->fetchAll();
Access pivot table data using the @ syntax:
// Find users who were assigned tags in the last hour
$hour = new \DateInterval("PT1H");
$recentUsers = $orm->getRepository(User::class)
->select()
->distinct()
->where('tags.@.assigned_at', '>', (new \DateTimeImmutable())->sub($hour))
->fetchAll();
Filter by pivot relations:
$users = $orm->getRepository(User::class)
->select()
->distinct()
->where('tags.@.assignedBy.role', 'admin')
->fetchAll();
To access pivot entity data, use the Doctrine collection with pivoted support:
use Cycle\ORM\Collection\Pivoted\PivotedCollection;
#[Entity]
class User
{
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
collection: 'doctrine'
)]
private PivotedCollection $tags;
public function __construct()
{
$this->tags = new PivotedCollection();
}
}
Access pivot data:
$user = $orm->getRepository(User::class)
->select()
->load('tags')
->wherePK(1)
->fetchOne();
foreach ($user->tags as $tag) {
$pivot = $user->tags->getPivot($tag);
echo sprintf(
"Tag '%s' assigned at %s with priority %d\n",
$tag->getName(),
$pivot->getAssignedAt()->format('Y-m-d H:i'),
$pivot->getPriority()
);
}
Create associations with custom pivot data:
$user = new User();
$user->setUsername("johndoe");
$tag = new Tag("php");
$pivot = new UserTag();
$pivot->setPriority(10);
$user->tags->add($tag);
$user->tags->setPivot($tag, $pivot);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
Pivot entities can have their own relations:
#[Entity]
class UserTag
{
#[Column(type: 'primary')]
private int $id;
#[BelongsTo(target: User::class)]
private User $assignedBy;
public function __construct(User $assignedBy)
{
$this->assignedBy = $assignedBy;
$this->assignedAt = new \DateTimeImmutable();
}
}
Load pivot relations:
$users = $orm->getRepository(User::class)
->select()
->load('tags.@.assignedBy') // Load pivot's assignedBy relation
->fetchAll();
Load with pivot-based sorting:
$users = $orm->getRepository(User::class)
->select()
->load('tags', [
'load' => function (\Cycle\ORM\Select\QueryBuilder $q) {
// @ = current relation, @.@ = pivot entity
$q->orderBy('@.@.priority', 'DESC');
}
])
->fetchAll();
Force single-query loading for complex scenarios:
$users = $orm->getRepository(User::class)
->select()
->load('tags', [
'method' => \Cycle\ORM\Select::SINGLE_QUERY,
'load' => function (\Cycle\ORM\Select\QueryBuilder $q) {
$q->where('@.@.priority', '>', 5)
->orderBy('@.@.priority', 'DESC');
}
])
->fetchAll();
Filter source, then load with pivot conditions:
$activeUsers = $orm->getRepository(User::class)
->select()
->where('active', true)
->load('tags', [
'where' => ['verified' => true],
'load' => function (\Cycle\ORM\Select\QueryBuilder $q) {
$q->where('@.@.priority', '>=', 5)
->orderBy('@.@.priority', 'DESC')
->orderBy('name', 'ASC');
}
])
->fetchAll();
ManyToMany supports different collection types:
#[ManyToMany(target: Tag::class, through: UserTag::class)]
private array $tags = [];
use Doctrine\Common\Collections\Collection;
use Cycle\ORM\Collection\Pivoted\PivotedCollection;
#[Entity]
class User
{
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
collection: 'doctrine'
)]
private PivotedCollection $tags;
public function __construct()
{
$this->tags = new PivotedCollection();
}
}
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
collection: 'my_custom_factory'
)]
private CustomCollection $tags;
Read more about relation collections.
Define bidirectional relationships:
use Cycle\Annotated\Annotation\Relation\Inverse;
#[Entity]
class User
{
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
inverse: new Inverse(as: 'users', type: 'manyToMany')
)]
private array $tags = [];
}
This automatically creates the inverse relation on Tag:
// Equivalent to defining on Tag:
#[Entity]
class Tag
{
#[ManyToMany(target: User::class, through: UserTag::class)]
private array $users = [];
}
By default, ManyToMany creates foreign keys in the pivot table with CASCADE:
#[ManyToMany(target: Tag::class, through: UserTag::class)]
private array $tags = [];
// Creates in UserTag:
// FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
// FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
When either User or Tag is deleted, pivot records are automatically removed.
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
fkAction: 'CASCADE', // Both DELETE and UPDATE
)]
private array $tags = [];
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
fkAction: 'CASCADE',
fkOnDelete: 'SET NULL',
nullable: true
)]
private array $tags = [];
Available Actions:
| Action | Description |
|---|---|
| CASCADE | Delete pivot records when source or target is deleted |
| SET NULL | Set FK to NULL (requires nullable: true) |
| NO ACTION | Prevent deletion if pivot records exist (database enforced) |
#[ManyToMany(
target: Tag::class,
through: UserTag::class,
fkCreate: false,
indexCreate: false
)]
private array $tags = [];
#[Entity]
class Post
{
#[ManyToMany(
target: Tag::class,
through: PostTag::class,
orderBy: ['name' => 'ASC']
)]
private array $tags = [];
public function hasTag(string $name): bool
{
foreach ($this->tags as $tag) {
if ($tag->getName() === $name) {
return true;
}
}
return false;
}
}
#[Entity]
class UserTag
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'datetime')]
private \DateTimeInterface $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
}
// Find recent associations
$users = $orm->getRepository(User::class)
->select()
->distinct()
->where('tags.@.created_at', '>', new DateTime('-7 days'))
->fetchAll();
#[Entity]
class UserSkill
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'integer')]
private int $level; // 1-10
public function setLevel(int $level): void
{
if ($level < 1 || $level > 10) {
throw new \InvalidArgumentException('Level must be 1-10');
}
$this->level = $level;
}
}
public function addTag(Tag $tag): void
{
foreach ($this->tags as $existingTag) {
if ($existingTag->getId() === $tag->getId()) {
return; // Already exists
}
}
$this->tags[] = $tag;
}
// Separate queries (default) - better for many relations
$user = $orm->getRepository(User::class)
->select()
->load('tags')
->wherePK(1)
->fetchOne();
// Single query with JOINs - better for filtering
$user = $orm->getRepository(User::class)
->select()
->with('tags')
->load('tags', ['using' => 'tags'])
->wherePK(1)
->fetchOne();
For large many-to-many collections, paginate on the target side:
$user = $orm->getRepository(User::class)->findByPK(1);
$tags = $orm->getRepository(Tag::class)
->select()
->with('users')
->where('users.id', $user->getId())
->orderBy('name')
->limit(20)
->offset(0)
->fetchAll();
Load multiple entities with their relations efficiently:
$users = $orm->getRepository(User::class)
->select()
->load('tags')
->where('active', true)
->fetchAll();
// Single query for users, single query for all tags