One-to-Many and Many-to-One
Which side holds the foreign key, why Doctrine only reads that side, and how to keep both sides of an association in step.
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'comments')] private ?Article $article = null;Two database tables are linked by a foreign key: a column in one table holding the primary key of a row in another. Doctrine calls that link an association, and maps it with attributes on the property that holds the other object.
Owning side and inverse side
Exactly one side holds the column. That side is the owning side, and for a one-to-many relation it is always the many side. A comment belongs to one article, so the comment table gets an article_id column and Comment::$article is mapped with #[ORM\ManyToOne].
The other side is the inverse side. Article::$comments is mapped with #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article')]. It adds no column to the article table. It is a view over the rows that point back. Note that targetEntity has to be spelled out here: Doctrine infers it from the property type only for to-one associations, and a Collection type says nothing about what it holds, so a to-many mapping without it fails with a mapping error.
Keeping both sides in step
Doctrine inspects only the owning side when working out what to write. Calling $article->getComments()->add($comment) and nothing else writes no row at all. Setting $comment->setArticle($article) does. The usual fix is an addComment() method on the article that does both, so the objects in memory agree with what the next flush will write.
Two defaults catch people out. A join column is nullable unless you say nullable: false, so a comment with no article is legal until you forbid it. And the collection property must be initialised in the constructor, or the first add() on a new entity reaches into null.
Example
<?php
// src/Entity/Comment.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Comment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::TEXT)]
private string $body = '';
// Owning side: the comment table carries the article_id column.
// Shorthand - targetEntity is read from the property type, and the
// join column name and referenced column come from the defaults.
#[ORM\ManyToOne(inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)]
// Longhand of the two lines above. Identical mapping, nothing implied:
// #[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'comments')]
// #[ORM\JoinColumn(name: 'article_id', referencedColumnName: 'id', nullable: false)]
private ?Article $article = null;
public function setBody(string $body): void
{
$this->body = $body;
}
public function setArticle(?Article $article): void
{
$this->article = $article;
}
}When to use it
- Attach comments to an article so each comment knows the article it belongs to.
- Give an invoice its lines, where the line table carries invoice_id.
- Record which category a product sits in without giving Category a products collection.
- Fix a save that appears to do nothing because only the inverse collection was changed.
More examples
The inverse side, with helpers that set both
The inverse side is a Collection and must be initialised in the constructor. mappedBy names the property on the other class that holds the foreign key. addComment() keeps the object graph honest: the collection and the comment's article agree, so code that reads the article after the call sees the new comment. removeComment() only drops the link in memory; it does not delete the comment or null its article column on its own.
<?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;
#[ORM\Column(length: 180)]
private string $title = '';
/** @var Collection<int, Comment> */
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article')]
private Collection $comments;
public function __construct()
{
// Without this, the first addComment() on a new Article fails.
$this->comments = new ArrayCollection();
}
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): void
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
}
// The owning side. Without this line, flush() writes no row.
$comment->setArticle($this);
}
public function removeComment(Comment $comment): void
{
$this->comments->removeElement($comment);
}
}Writing one row: persist schedules, flush writes
persist() marks the object for insertion and runs no SQL. flush() commits the unit of work and is the only call that writes. The flush here produces one INSERT into comment with article_id set from the owning side. Reach for this shape whenever a request creates a child row: build the object, link it through the parent's add method, persist the new entity, flush once at the end.
<?php
// src/Service/CommentPublisher.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Entity\Comment;
use Doctrine\ORM\EntityManagerInterface;
final class CommentPublisher
{
// Injected through the constructor, type-hinted on the interface.
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
public function publish(Article $article, string $body): Comment
{
$comment = new Comment();
$comment->setBody($body);
// Sets both sides; the owning side is what the INSERT reads.
$article->addComment($comment);
$this->entityManager->persist($comment); // schedules the insert
$this->entityManager->flush(); // executes it
return $comment;
}
}Unidirectional: no inverse collection at all
A many-to-one does not need a matching one-to-many. The foreign key and the mapping live entirely on this class. You still get the audit entries for an article by querying the repository for them, and the Article entity stays small. Use this when you never navigate from the parent to the children in code, only the other way round.
<?php
// src/Entity/AuditEntry.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class AuditEntry
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 40)]
private string $action = '';
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $recordedAt;
// No inversedBy: Article has no $auditEntries property and needs none.
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Article $article = null;
public function __construct(\DateTimeImmutable $recordedAt)
{
$this->recordedAt = $recordedAt;
}
}
Discussion