Documentation on "orphanRemoval" is ambiguous (if not wrong) #7273

Open
opened 2026-01-22 15:48:40 +01:00 by admin · 4 comments
Owner

Originally created by @janopae on GitHub (Dec 6, 2023).

Bug Report

Summary

The documentation about orphanRemovel currently states the following:

When using the orphanRemoval=true option Doctrine makes the assumption that the entities are privately owned and will NOT be reused by other entities. If you neglect this assumption your entities will get deleted by Doctrine even if you assigned the orphaned entity to another one.

Is is not clear however what "privately owned" means in that context. Further, it is not clear whether "entity" in this case refers to an entity class or to an instance of an entity class.

In the paragraph before, the term "entity" seems to be referring to a single instance of an entity class. Therefore, "entities will get deleted by Doctrine even if you assigned the orphaned entity to another one" would mean that assigning a child entity to a new parent would also make doctrine remove them as an orphan.

Impact

When I was reading the docs, I thought orphanRemoval would be unsuitable for my use case: I have a parent and a child class, the parent being the "aggregate root" (in terms of Domain Driven Design that means that there is no repository for the child, just for the parent). I wanted children to be removed when they have no parent, so you could remove them on the parent class without having to call the entity manager. However, child classes should also be able to move to another parent.

After some hours of searching for alternatives, I decided to give it a shot anyway, and to my surprise it worked as desired.

The warning might prevent many from using orphanRemoval in the first place, implying it only served a niche purpose, while it actually would exactly solve their problem.

How to reproduce

Consider the following example entities:

<?php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 */
class ParentClass
{
    /**
     * @ORM\Id()
     *
     * @ORM\GeneratedValue()
     *
     * @ORM\Column(type="integer")
     */
    private ?int $id = null;

    /**
     * @ORM\OneToMany(targetEntity="Child", mappedBy="parent", cascade={"persist", "remove"}, orphanRemoval=true)
     *
     * @var Collection<int,Child>
     */
    private Collection $children;

    public function __construct()
    {
        $this->children = new ArrayCollection();
    }

    /**
     * @return list<Child>
     */
    public function getChildren(): array
    {
        return $this->children->toArray();
    }

    public function addChild(Child $child)
    {
        if ($this !== $child->getParent()) {
            throw new \InvalidArgumentException();
        }

        $this->children->add($child);
    }

    public function removeChild(Child $child): bool
    {
        return $this->children->removeElement($child);
    }
}

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**a
 * @ORM\Entity()
 */
class Child
{
    /**
     * @ORM\Id()
     *
     * @ORM\GeneratedValue()
     *
     * @ORM\Column(type="integer")
     */
    private ?int $id = null;

    /**
     * @ORM\ManyToOne(targetEntity="ParentClass", inversedBy="children", cascade={"persist"})
     */
    private ParentClass $parent;

    public function __construct(ParentClass $parent)
    {
        $this->parent = $parent;
        $this->parent->addChild($this);
    }

    public function setParent(ParentClass $parent)
    {
        $this->parent->removeChild($this);
        $this->parent = $parent;
        $this->parent->addChild($this);
    }

    public function getParent(): ParentClass
    {
        return $this->parent;
    }
}

According to the documentation, you'd expect the second of the following test cases to fail:


<?php

namespace App;

use PHPUnit\Framework\TestCase;
use App\Entity\Child;
use App\Entity\ParentClass;
use Webfactory\Doctrine\ORMTestInfrastructure\ORMInfrastructure;

class OrphanRemovalTest extends TestCase
{
    // This test uses webfactory/doctrine-orm-test-infrastructure to automatically create an in-memory database, so we
    // don't have to manually set up a database with a schema for our test entities.
    //
    // To install, run `composer require --dev webfactory/doctrine-orm-test-infrastructure`
    private ORMInfrastructure $infrastructure;

    protected function setUp(): void
    {
        $this->infrastructure = new ORMInfrastructure(
            [
                ParentClass::class,
                Child::class,
            ]
        );
    }

    /**
     * @test
     */
    public function orphanRemovel_does_remove_child_when_it_has_no_parent(): void
    {
        // This just tests the expected behaviour to ensure orphanRemoval has been configured correctly

        $parent1 = new ParentClass();
        new Child($parent1);

        $this->infrastructure->getEntityManager()->persist($parent1);
        $this->infrastructure->getEntityManager()->flush();
        [$parent1] = $this->infrastructure->getRepository(ParentClass::class)->findAll();

        $child = $parent1->getChildren()[0];
        $parent1->removeChild($child);

        $this->infrastructure->getEntityManager()->flush();

        $children = $this->infrastructure->getRepository(Child::class)->findAll();

        self::assertEmpty($children);
    }

    /**
     * @test
     */
    public function orphanRemoval_does_not_remove_child_if_it_got_assigned_to_a_new_parent(): void
    {
        // This test ist the interesting one

        $parent1 = new ParentClass();
        $parent2 = new ParentClass();
        new Child($parent1);

        $this->infrastructure->getEntityManager()->persist($parent1);
        $this->infrastructure->getEntityManager()->persist($parent2);
        $this->infrastructure->getEntityManager()->flush();
        [$parent1, $parent2] = $this->infrastructure->getRepository(ParentClass::class)->findAll();

        $parent1->getChildren()[0]->setParent($parent2);

        $this->infrastructure->getEntityManager()->flush();
        [$parent1, $parent2] = $this->infrastructure->getRepository(ParentClass::class)->findAll();

        self::assertCount(0, $parent1->getChildren());
        self::assertCount(1, $parent2->getChildren());

        $children = $this->infrastructure->getRepository(Child::class)->findAll();

        self::assertCount(1, $children);

        // $child has not been removed, despite having been removed from $parent1::children – a good thing IMHO, as
        // it is now part of $parent2::children.
        //
        // The docs however say "When using the orphanRemoval=true option Doctrine makes the assumption that the
        // entities are privately owned and will NOT be reused by other entities. If you neglect this assumption your
        // entities will get deleted by Doctrine even if you assigned the orphaned entity to another one."
        //
        // In the paragraph right before, the term "entity" referred to an *instance* of a class – which in this case
        // would mean that even assigning the child to a new parent wouldn't prevent Doctrine from deleting it.
    }

}

However, it succeeds as desired.

I'd argue that this is good behaviour and should be kept – the documentation should be adjusted. What do you think?

I thought about creating a PR for the docs btw., but I think I don't yet know enough about how this works with ManyToMany relationships, and how Doctrine behaves if you have multiple relationships to a child with orphanRemoval=true.

Originally created by @janopae on GitHub (Dec 6, 2023). ### Bug Report #### Summary The [documentation about orphanRemovel](https://www.doctrine-project.org/projects/doctrine-orm/en/2.18/reference/working-with-associations.html#orphan-removal) currently states the following: > When using the `orphanRemoval=true` option Doctrine makes the assumption that the entities are privately owned and will **NOT** be reused by other entities. If you neglect this assumption your entities will get deleted by Doctrine even if you assigned the orphaned entity to another one. Is is not clear however what "privately owned" means in that context. Further, it is not clear whether "entity" in this case refers to an entity class or to an instance of an entity class. In the paragraph before, the term "entity" seems to be referring to a single instance of an entity class. Therefore, "entities will get deleted by Doctrine even if you assigned the orphaned entity to another one" would mean that assigning a child entity to a new parent would also make doctrine remove them as an orphan. #### Impact When I was reading the docs, I thought `orphanRemoval` would be unsuitable for my use case: I have a parent and a child class, the parent being the "aggregate root" (in terms of Domain Driven Design that means that there is no repository for the child, just for the parent). I wanted children to be removed when they have no parent, so you could remove them on the parent class without having to call the entity manager. However, child classes should also be able to move to another parent. After some hours of searching for alternatives, I decided to give it a shot anyway, and to my surprise it worked as desired. The warning might prevent many from using `orphanRemoval` in the first place, implying it only served a niche purpose, while it actually would exactly solve their problem. #### How to reproduce Consider the following example entities: ```php <?php namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() */ class ParentClass { /** * @ORM\Id() * * @ORM\GeneratedValue() * * @ORM\Column(type="integer") */ private ?int $id = null; /** * @ORM\OneToMany(targetEntity="Child", mappedBy="parent", cascade={"persist", "remove"}, orphanRemoval=true) * * @var Collection<int,Child> */ private Collection $children; public function __construct() { $this->children = new ArrayCollection(); } /** * @return list<Child> */ public function getChildren(): array { return $this->children->toArray(); } public function addChild(Child $child) { if ($this !== $child->getParent()) { throw new \InvalidArgumentException(); } $this->children->add($child); } public function removeChild(Child $child): bool { return $this->children->removeElement($child); } } ``` ```php <?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; /**a * @ORM\Entity() */ class Child { /** * @ORM\Id() * * @ORM\GeneratedValue() * * @ORM\Column(type="integer") */ private ?int $id = null; /** * @ORM\ManyToOne(targetEntity="ParentClass", inversedBy="children", cascade={"persist"}) */ private ParentClass $parent; public function __construct(ParentClass $parent) { $this->parent = $parent; $this->parent->addChild($this); } public function setParent(ParentClass $parent) { $this->parent->removeChild($this); $this->parent = $parent; $this->parent->addChild($this); } public function getParent(): ParentClass { return $this->parent; } } ``` According to the documentation, you'd expect the second of the following test cases to fail: ```php <?php namespace App; use PHPUnit\Framework\TestCase; use App\Entity\Child; use App\Entity\ParentClass; use Webfactory\Doctrine\ORMTestInfrastructure\ORMInfrastructure; class OrphanRemovalTest extends TestCase { // This test uses webfactory/doctrine-orm-test-infrastructure to automatically create an in-memory database, so we // don't have to manually set up a database with a schema for our test entities. // // To install, run `composer require --dev webfactory/doctrine-orm-test-infrastructure` private ORMInfrastructure $infrastructure; protected function setUp(): void { $this->infrastructure = new ORMInfrastructure( [ ParentClass::class, Child::class, ] ); } /** * @test */ public function orphanRemovel_does_remove_child_when_it_has_no_parent(): void { // This just tests the expected behaviour to ensure orphanRemoval has been configured correctly $parent1 = new ParentClass(); new Child($parent1); $this->infrastructure->getEntityManager()->persist($parent1); $this->infrastructure->getEntityManager()->flush(); [$parent1] = $this->infrastructure->getRepository(ParentClass::class)->findAll(); $child = $parent1->getChildren()[0]; $parent1->removeChild($child); $this->infrastructure->getEntityManager()->flush(); $children = $this->infrastructure->getRepository(Child::class)->findAll(); self::assertEmpty($children); } /** * @test */ public function orphanRemoval_does_not_remove_child_if_it_got_assigned_to_a_new_parent(): void { // This test ist the interesting one $parent1 = new ParentClass(); $parent2 = new ParentClass(); new Child($parent1); $this->infrastructure->getEntityManager()->persist($parent1); $this->infrastructure->getEntityManager()->persist($parent2); $this->infrastructure->getEntityManager()->flush(); [$parent1, $parent2] = $this->infrastructure->getRepository(ParentClass::class)->findAll(); $parent1->getChildren()[0]->setParent($parent2); $this->infrastructure->getEntityManager()->flush(); [$parent1, $parent2] = $this->infrastructure->getRepository(ParentClass::class)->findAll(); self::assertCount(0, $parent1->getChildren()); self::assertCount(1, $parent2->getChildren()); $children = $this->infrastructure->getRepository(Child::class)->findAll(); self::assertCount(1, $children); // $child has not been removed, despite having been removed from $parent1::children – a good thing IMHO, as // it is now part of $parent2::children. // // The docs however say "When using the orphanRemoval=true option Doctrine makes the assumption that the // entities are privately owned and will NOT be reused by other entities. If you neglect this assumption your // entities will get deleted by Doctrine even if you assigned the orphaned entity to another one." // // In the paragraph right before, the term "entity" referred to an *instance* of a class – which in this case // would mean that even assigning the child to a new parent wouldn't prevent Doctrine from deleting it. } } ``` However, it succeeds as desired. I'd argue that this is good behaviour and should be kept – the documentation should be adjusted. What do you think? I thought about creating a PR for the docs btw., but I think I don't yet know enough about how this works with ManyToMany relationships, and how Doctrine behaves if you have multiple relationships to a child with orphanRemoval=true.
Author
Owner

@jwandrews commented on GitHub (Jan 31, 2024):

came across this while looking for a different issue i have with orphan removal.

if i understand the documentation correctly, the original child entity is deleted with orphan removal being true. your test assertions aren't necessarily testing for equality between the 2 children. i think you may find that during a test run, if you checked the ids of the children before and after 'moving' between parents, they would be different since doctrine will remove $parent1's child and create a new child on $parent2 vs simply updating the parent reference on the child

@jwandrews commented on GitHub (Jan 31, 2024): came across this while looking for a different issue i have with orphan removal. if i understand the documentation correctly, the original child entity is deleted with orphan removal being true. your test assertions aren't necessarily testing for equality between the 2 children. i think you may find that during a test run, if you checked the ids of the children before and after 'moving' between parents, they would be different since doctrine will remove `$parent1`'s child and create a new child on `$parent2` vs simply updating the parent reference on the child
Author
Owner

@janopae commented on GitHub (Feb 2, 2024):

You could be right. Thanks for sharing this observation!

@janopae commented on GitHub (Feb 2, 2024): You could be right. Thanks for sharing this observation!
Author
Owner

@jwandrews commented on GitHub (Feb 2, 2024):

FWIW, the issue I was/am having with orphanRemoval, when i found this issue, is that it apparently does not work as expected on OneToOne relationships. I was under the assumption it would. It does however work as the documentation states on ManyToOne relationships.

@jwandrews commented on GitHub (Feb 2, 2024): FWIW, the issue I was/am having with `orphanRemoval`, when i found this issue, is that it apparently does not work as expected on `OneToOne` relationships. I was under the assumption it would. It does however work as the documentation states on `ManyToOne` relationships.
Author
Owner

@janopae commented on GitHub (Feb 2, 2024):

What exactly is wrong with OneToOne relationships? Maybe you can create a separate issue explaining this?

@janopae commented on GitHub (Feb 2, 2024): What exactly is wrong with OneToOne relationships? Maybe you can create a separate issue explaining this?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: doctrine/archived-orm#7273