Cascades and Orphans
How cascade persist and remove travel along associations, what orphanRemoval deletes, and why a cascade can delete too much.
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article', cascade: ['persist'], orphanRemoval: true)]An association can pass operations along. cascade on the mapping says which ones: persist, remove, detach, refresh, or all for the lot. It is declared on one side and travels in that direction only.
cascade persist
With cascade: ['persist'], persisting the parent persists the new children reachable from it, and new objects added to the collection of an already-saved parent are saved by the next flush. Without it, a new object in the collection is an error on flush rather than an insert: Doctrine will not guess that you meant to save it.
cascade remove and orphanRemoval
These two are often confused. cascade: ['remove'] acts when the parent is removed: the children are loaded and deleted too. orphanRemoval: true acts when a child is taken out of the collection: the child is deleted, because the mapping declares it privately owned and not reusable elsewhere.
So $article->removeComment($comment) deletes that comment under orphanRemoval, and only unlinks it without. Note that orphanRemoval implies the remove cascade: deleting the parent deletes the children either way.
The delete that took too much
A remove cascade on a many-to-one points at the parent. Putting cascade: ['remove'] on Comment::$article means deleting one comment deletes its article, which then cascades back and deletes every other comment on it. The mapping looks symmetrical and is not. Put remove cascades only on the side that owns the other's lifetime, and never on a reference to something shared.
Cascades are ORM-level: Doctrine loads each child and issues a DELETE per row. A database ON DELETE CASCADE is one statement, but the ORM never sees it, so lifecycle callbacks and already-loaded objects go stale.
Example
<?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;
/** @var Collection<int, Comment> */
#[ORM\OneToMany(
targetEntity: Comment::class,
mappedBy: 'article',
// New comments reachable from this article are saved with it.
cascade: ['persist'],
// A comment taken out of this collection is deleted, not orphaned.
orphanRemoval: true,
)]
private Collection $comments;
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function addComment(Comment $comment): void
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
}
$comment->setArticle($this);
}
public function removeComment(Comment $comment): void
{
if ($this->comments->removeElement($comment)) {
// Keep the owning side consistent; orphanRemoval turns this
// into a DELETE on the next flush().
if ($comment->getArticle() === $this) {
$comment->setArticle(null);
}
}
}
}When to use it
- Save an article and the comments created with it in a single flush.
- Delete a form collection row by removing it from the collection in the parent.
- Stop a child delete from taking its parent with it.
- Replace a slow ORM cascade over thousands of rows with a database-level cascade.
More examples
One flush, parent and children together
One persist() and one flush() store the article and all of its comments: cascade: ['persist'] on Article::$comments passes the persist call along the association, and any new entity found in that collection during flush is inserted too, which Doctrine calls persistence by reachability. Without the cascade the same code fails on flush rather than inserting, because Doctrine will not guess that an unmanaged Comment reachable from the article should be saved. The flush writes the article first and then one INSERT per comment, since each comment needs the article's generated id for its article_id column.
<?php
// src/Service/ArticleImporter.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Entity\Comment;
use Doctrine\ORM\EntityManagerInterface;
final class ArticleImporter
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
/**
* @param list<string> $bodies
*/
public function import(array $bodies): Article
{
$article = new Article();
foreach ($bodies as $body) {
$comment = new Comment();
$comment->setBody($body);
// Sets both sides, so the owning side is ready for the insert.
$article->addComment($comment);
}
// Only the article is persisted. cascade: ['persist'] carries the
// call along the association to every new comment in it.
$this->entityManager->persist($article);
$this->entityManager->flush();
return $article;
}
}Deleting the parent
The ORM deletes row by row because it has to run its own bookkeeping and any lifecycle callbacks on each child. That is fine for a handful of children and slow for ten thousand. When a parent routinely owns very many rows, delete them with a single DQL DELETE or let the database do it with ON DELETE CASCADE, and accept that in-memory objects for those rows are then stale.
<?php
// src/Service/ArticleRemover.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
final class ArticleRemover
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {
}
public function remove(Article $article): void
{
// orphanRemoval (or cascade: ['remove']) means Doctrine loads the
// comments and schedules a DELETE for each one, then the article.
// Three comments produce four DELETE statements, not one.
$this->entityManager->remove($article);
$this->entityManager->flush();
}
}A database-level cascade instead
onDelete belongs to the JoinColumn, not to cascade, and it changes the generated DDL rather than any PHP behaviour. It is the cheap way to guarantee no orphan rows survive, including deletes made by hand in a SQL client. Use it as a safety net under the schema, and keep the ORM-level cascade or orphanRemoval if you depend on events or on the entity manager knowing what went. Adding it later needs a migration that drops and recreates the foreign key.
<?php
// src/Entity/Comment.php (extract)
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
// Inside the Comment entity.
#[ORM\ManyToOne(inversedBy: 'comments')]
// onDelete is emitted into the schema as part of the foreign key:
// FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE
// The database removes the comment rows itself. Doctrine is not involved,
// so no lifecycle callbacks run and loaded Comment objects go stale.
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Article $article = null;
Discussion