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 TaskIn 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:
<?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
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:
| Entity | Role |
|---|---|
| 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:migrateCreates 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).