Removing Related Entities in Doctrine

The article shows what options need to set for removing related entities in Doctrine.

Doctrine has several options for removing related entities:

  • cascade=["remove"]
  • orphanRemoval=true
  • onDelete="CASCADE | SET NULL"

Doctrine wraps INSERT, UPDATE, DELETE SQL statements in a transaction by default.

cascade=["remove"]

When cascade: ['remove'] is specified, remove will also be executed on the associated entity.

Doesn't require creating a migration as there are no changes to the database schema.

OneToOne

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\OneToOne(mappedBy: 'product', cascade: ['remove'])]
    private ?Photo $photo = null;
}
#[ORM\Entity(repositoryClass: PhotoRepository::class)]
class Photo
{
    #[ORM\OneToOne(inversedBy: 'photo')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Product $product = null;
}

FOREIGN KEY is set on the side of the Photo entity.

Getting and removing the Product entity:

$product = $em->getRepository(Product::class)->find(1);

$em->remove($product);
$em->flush();

Doctrine will generate SQL queries:

SELECT * FROM product t0 LEFT JOIN photo t8 ON t8.product_id = t0.id WHERE t0.id = 1;
"START TRANSACTION";
DELETE FROM photo WHERE id = 1;
DELETE FROM product WHERE id = 1;
"COMMIT";

The SQL queries show that, in addition to the Product entity, the Photo entity was also removed.

OneToMany

SQL queries will be similar as for the OneToOne relation type.

ManyToOne

Doesn't make sense, since the related entity may still be needed by other entities. For example, when removing a product, you should not remove the category to which it belongs, as other products may still belong to this category.

ManyToMany

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\ManyToMany(targetEntity: Stock::class, inversedBy: 'products')]
    private Collection $stocks;
}
#[ORM\Entity(repositoryClass: StockRepository::class)]
class Stock
{
    #[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'stocks', cascade: ['remove'])]
    private Collection $products;
}

FOREIGN KEY's for the Product and Stock entities are set in the related table product_stock.

Getting and removing a Stock entity:

$stock = $em->getRepository(Stock::class)->find(1);

$em->remove($stock);
$em->flush();

Doctrine will generate SQL queries:

SELECT t0.id AS id_1 FROM stock t0 WHERE t0.id = 1;
SELECT * FROM product t0 
INNER JOIN product_stock ON t0.id = product_stock.product_id
WHERE product_stock.stock_id = 1;
"START TRANSACTION";
DELETE FROM product WHERE id = 1;
DELETE FROM product WHERE id = 2;
DELETE FROM product_stock WHERE stock_id = 1;
DELETE FROM stock WHERE id = 1;
"COMMIT";

It can be seen from the SQL queries that the Stock entity, the relations in the related table product_stock and the Product entities belonging to this Stock entity have been removed.

For this relation type, if cascade: ['remove'] is set, both the relations in the product_stock table and related Product entities will be removed, not just the relations in the product_stock table.

If don't set cascade: ['remove'], then only the Stock entity and the relation in the product_stock table will be removed.

orphanRemoval=true

The orphanRemoval option means whether the reassignment of one entity to another will be allowed.
orphanRemoval: true has the same effect as cascade['remove'] with the additional prohibition of entity reassignment.

The option is available for relation types: OneToOne, OneToMany, ManyToMany.
Doesn't require creating a migration as there are no changes to the database schema.

OneToOne

Without specifying orphanRemoval=true

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\OneToOne()]
    private ?Photo $photo = null;
}
#[ORM\Entity(repositoryClass: PhotoRepository::class)]
class Photo
{
    // the relation is set on the Product side
}

FOREIGN KEY is set on the side of the Product entity.

Reassign the Photo entity belonging to the Product entity to another Product entity:

$product = $em->getRepository(Product::class)->find(1);
$productTwo = $em->getRepository(Product::class)->find(2);
$photo = $productTwo->getPhoto();

// first, nullify the relation between Photo and the Product entity
// flush() is needed to avoid an SQL error: Key (photo_id)=(2) already exists
$productTwo->setPhoto(null);
$em->flush();

// assign the Photo entity to another Product entity
$product->setPhoto($photo);
$em->flush();

Doctrine will generate SQL queries:

SELECT * FROM product t0 WHERE t0.id = 1;
SELECT * FROM product t0 WHERE t0.id = 2;
"START TRANSACTION";
UPDATE product SET photo_id = NULL WHERE id = 2;
"COMMIT";
"START TRANSACTION";
UPDATE product SET photo_id = 2 WHERE id = 1;
"COMMIT";

It can be seen from the SQL queries that the Product entity has an updated relation with the Photo entity.

With specifying orphanRemoval=true

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\OneToOne(orphanRemoval: true)]
    private ?Photo $photo = null;
}
#[ORM\Entity(repositoryClass: PhotoRepository::class)]
class Photo
{
    // the relation is set on the Product side
}

FOREIGN KEY is set on the side of the Product entity.

Reassign the Photo entity belonging to the Product entity to another Product entity:

$product = $em->getRepository(Product::class)->find(1);
$productTwo = $em->getRepository(Product::class)->find(2);
$photo = $productTwo->getPhoto();

// first, nullify the relation between Photo and the Product entity
// flush() is needed to avoid an SQL error: Key (photo_id)=(2) already exists
$productTwo->setPhoto(null);
$em->flush();

// assign the Photo entity to another Product entity
$product->setPhoto($photo);
$em->flush();

Doctrine will throw an error:

Unable to find "Proxies\__CG__\App\Entity\Photo" entity identifier associated with the UnitOfWork

This means that the Photo entity cannot be reassigned to another Product entity. This is the meaning of orphanRemoval=true. The Product entity owns the Photo entity and it cannot be assigned to another Product entity.

OneToMany

Without specifying orphanRemoval=true

#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)]
    private $products;
}
#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\ManyToOne(inversedBy: 'products')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Category $category = null;
}

FOREIGN KEY is set on the side of the Product entity.

For the Product entity, assign another Category entity and remove the old one:

$product = $em->getRepository(Product::class)->find(3);
$category = $product->getCategory();

$productTwo = $em->getRepository(Product::class)->find(2);

// remove the Category entity
$em->remove($category);

// assign a new Category entity to the Product entity
$product->setCategory($productTwo->getCategory());
$em->flush();

Doctrine will generate SQL queries:

SELECT * FROM product t0 WHERE t0.id = 3;
SELECT * FROM product t0 WHERE t0.id = 2;
"START TRANSACTION"
UPDATE product SET category_id = 2 WHERE id = 3;
DELETE FROM category WHERE id = 3;
"COMMIT";

From the SQL queries, it can be seen that the relation with the Category entity has been updated for the Product entity and the Category entity has been removed.

With specifying orphanRemoval=true

#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class, orphanRemoval: true)]
    private $products;
}
#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\ManyToOne(inversedBy: 'products')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Category $category = null;
}

FOREIGN KEY is set on the side of the Product entity.

For the Product entity, assign another Category entity and remove the old one:

$product = $em->getRepository(Product::class)->find(3);
$category = $product->getCategory();

$productTwo = $em->getRepository(Product::class)->find(2);

// remove the Category entity
$em->remove($category);

// assign a new Category entity to the Product entity
$product->setCategory($productTwo->getCategory());
$em->flush();

Doctrine will generate SQL queries:

SELECT * FROM product t0 WHERE t0.id = 3;
SELECT * FROM product t0 WHERE t0.id = 2;
SELECT * FROM category t0 WHERE t0.id = 3;
SELECT * FROM product t0 WHERE t0.category_id = 3;
"START TRANSACTION"
DELETE FROM product WHERE id = 3;
DELETE FROM category WHERE id = 3;
"COMMIT";

According to the SQL queries, it is clear that it was not possible to update the relation with the Category entity; instead, both the Category entity itself and the Product entity that is associated with it were removed.

ManyToMany (not working)

#[ORM\Entity(repositoryClass: StockRepository::class)]
class Stock
{
    #[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'stocks', orphanRemoval: true)]
    private Collection $products;
}
#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\ManyToMany(targetEntity: Stock::class, inversedBy: 'products')]
    private Collection $stocks;
}

The relation to the Product and Stock entities is in the related table product_stock.

Reassign all Product entities to another Stock entity:

$stock = $em->getRepository(Stock::class)->find(1);
$stockTwo = $em->getRepository(Stock::class)->find(2);

$em->remove($stock);

foreach ($stock->getProducts() as $product) {
    $product->addStock($stockTwo);
}

$em->flush();

Doctrine will generate SQL queries:

SELECT * FROM stock t0 WHERE t0.id = 1;
SELECT * FROM stock t0 WHERE t0.id = 2;
SELECT * FROM product t0 INNER JOIN product_stock ON t0.id = product_stock.product_id WHERE product_stock.stock_id = 1;
SELECT * FROM stock t0 INNER JOIN product_stock ON t0.id = product_stock.stock_id WHERE product_stock.product_id = 1;
SELECT * FROM stock t0 INNER JOIN product_stock ON t0.id = product_stock.stock_id WHERE product_stock.product_id = 2;
"START TRANSACTION";
INSERT INTO product_stock (product_id, stock_id) VALUES (1, 2);
INSERT INTO product_stock (product_id, stock_id) VALUES (2, 2);
DELETE FROM product_stock WHERE stock_id = 1;
DELETE FROM stock WHERE id = 1;
"COMMIT";

The SQL queries show that the relation between the Product entities and another Stock entity has been updated.

orphanRemoval: true for the ManyToMany relation type does not work.

onDelete="CASCADE"

The onDelete="CASCADE" option means that when an entity is removed, dependent entities will also be removed by database itself, that is, only one DELETE SQL query will be executed.

Requires a migration to be created as the database schema must be changed.

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\ManyToOne(inversedBy: 'products')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?Category $category = null;
}
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)]
    private $products;
}

FOREIGN KEY is set on the side of the Product entity.

Removing the Category entity:

$category = $em->getRepository(Category::class)->find(1);

$em->remove($category);
$em->flush();

Doctrine will generate SQL queries:

SELECT * FROM category t0 WHERE t0.id = 1;
"START TRANSACTION";
DELETE FROM category WHERE id = 1;
"COMMIT";

From the SQL queries, it can be seen that the Category entity was removed, but if you look at the product table, you can see that all Product entities associated with this Category entity have been removed.

onDelete="SET NULL"

#[ORM\Entity(repositoryClass: ProductRepository::class)]
class Product
{
    #[ORM\ManyToOne(inversedBy: 'products')]
    #[ORM\JoinColumn(nullable: false, onDelete: 'SET NULL')]
    private ?Category $category = null;
}
#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)]
    private $products;
}

FOREIGN KEY is set on the side of the Product entity.

Removing the Category entity:

$category = $em->getRepository(Category::class)->find(1);

$em->remove($category);
$em->flush();

Doctrine will generate SQL queries:

SELECT * FROM category t0 WHERE t0.id = 1;
"START TRANSACTION";
DELETE FROM category WHERE id = 1;
"COMMIT";

From the SQL queries, can see that the Category entity was removed, but if you look in the product table, can see that the record was not removed, and the relation with the Category entity is set to NULL.