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

Relationships: OneToMany and ManyToOne

Relationships: OneToMany and ManyToOne

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

Now let's model the first REAL relationship from chapter 5: a Project has SEVERAL Tasks, each Task belongs to EXACTLY one Project.

Generating the Task entity

php bin/console make:entity Task

In the interactive field prompt, choose project as a "relation" field – make:entity then asks for the relationship type (ManyToOne) and automatically creates BOTH sides of the relationship:

src/Entity/Task.php
<?php

declare(strict_types=1);

namespace App\Entity;

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

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

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

    #[ORM\Column(length: 20)]
    private string $status = 'open';

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $dueAt = null;

    #[ORM\ManyToOne(inversedBy: 'tasks')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Project $project = null;

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

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getStatus(): string
    {
        return $this->status;
    }

    public function setStatus(string $status): static
    {
        $this->status = $status;

        return $this;
    }

    public function getDueAt(): ?\DateTimeImmutable
    {
        return $this->dueAt;
    }

    public function setDueAt(?\DateTimeImmutable $dueAt): static
    {
        $this->dueAt = $dueAt;

        return $this;
    }

    public function getProject(): ?Project
    {
        return $this->project;
    }

    public function setProject(?Project $project): static
    {
        $this->project = $project;

        return $this;
    }
}

The other side of the relationship: extending Project

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

class Project
{
    // ... existing fields from chapter 19 ...

    /**
     * @var Collection<int, Task>
     */
    #[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'project', orphanRemoval: true)]
    private Collection $tasks;

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

    /**
     * @return Collection<int, Task>
     */
    public function getTasks(): Collection
    {
        return $this->tasks;
    }

    public function addTask(Task $task): static
    {
        if (!$this->tasks->contains($task)) {
            $this->tasks->add($task);
            $task->setProject($this);
        }

        return $this;
    }

    public function removeTask(Task $task): static
    {
        if ($this->tasks->removeElement($task)) {
            if ($task->getProject() === $this) {
                $task->setProject(null);
            }
        }

        return $this;
    }
}

Owning side vs. inverse side: the decisive difference

In Doctrine, a relationship is only actually stored in the database on ONE side – the owning side:

EntityRole
Task (owning side)Carries #[ORM\ManyToOne] AND #[ORM\JoinColumn] – EXACTLY here the actual project_id foreign key column is created in the database.
Project (inverse side)Carries #[ORM\OneToMany(mappedBy: 'project')] – NO own database column, mappedBy points to the property name (project) on the owning side.

Achtung: A common beginner mistake: forgetting $task->setProject($project) and ONLY calling $project->addTask($task) – since Project is the INVERSE side, that has NO effect on the database! That's exactly why addTask() INTERNALLY calls $task->setProject($this) itself – synchronizing BOTH sides is standard best practice for bidirectional relationships.

Migration for the new relationship

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Creates a task table with a project_id foreign key AND its FOREIGN KEY constraint – EXACTLY the five-step workflow from chapter 20.

orphanRemoval: automatically deleting orphaned tasks

orphanRemoval: true on the Project side makes this happen: remove a Task from $project->getTasks() (via removeTask()), and Doctrine AUTOMATICALLY deletes it from the database too, instead of leaving it "orphaned" with project_id = NULL (which would be impossible anyway, since JoinColumn(nullable: false) forbids it).