Cascading
Cascade operations automatically propagate persist and remove operations between related entities, reducing boilerplate and keeping related entities synchronized. Persist and remove are configured on opposite sides of the relationship — this page covers both.
What are Cascade Operations?
Without cascade, you must explicitly persist every entity you want saved. With cascade persist configured on the parent's collection, ObjectQuel persists new children automatically when you persist the parent:
class OrderEntity {
/**
* @Orm\InverseOf(targetEntity="OrderItemEntity", relation="order")
* @Orm\Cascade(operations={"persist"})
*/
public EntityCollection $items;
}
// Without cascade: every entity must be persisted individually
$order = new OrderEntity();
$item1 = new OrderItemEntity();
$item2 = new OrderItemEntity();
$entityManager->persist($order);
$entityManager->persist($item1); // required without cascade
$entityManager->persist($item2); // required without cascade
$entityManager->flush();
// With cascade: persisting the order is enough
$order = new OrderEntity();
$order->items->add(new OrderItemEntity());
$order->items->add(new OrderItemEntity());
$entityManager->persist($order); // items are persisted automatically
$entityManager->flush();
@Orm\InverseOf collection, as above. Remove is declared on the child's @Orm\ManyToOne / @Orm\OneToOne property instead — see "Cascade Remove" below. Declaring remove on an @Orm\InverseOf property is rejected when entity metadata is built.
Cascade Persist
When cascade persist is set on an @Orm\InverseOf collection, any new child entity added to that collection is automatically persisted along with the parent. This is useful for composition relationships where children are created as part of building the parent.
class OrderEntity {
/**
* @Orm\InverseOf(targetEntity="OrderItemEntity", relation="order")
* @Orm\Cascade(operations={"persist"})
*/
public EntityCollection $items;
public function __construct() {
$this->items = new EntityCollection();
}
}
$order = new OrderEntity();
$order->setCustomerId(123);
$item1 = new OrderItemEntity();
$item1->setProductId(1);
$item1->setQuantity(2);
$item2 = new OrderItemEntity();
$item2->setProductId(2);
$item2->setQuantity(1);
$order->items->add($item1);
$order->items->add($item2);
$entityManager->persist($order);
$entityManager->flush(); // both items are saved automatically
Cascade Remove
Cascade remove isn't declared on the parent's collection — it's declared on the child's @Orm\ManyToOne (or @Orm\OneToOne) property, the one that points back at the parent. When the parent is removed, ObjectQuel looks at every entity class with a relationship targeting the parent's type, checks that property for @Orm\Cascade(operations={"remove"}), and — if it's set — queries the child table directly by foreign key and removes every matching row.
class OrderItemEntity {
/**
* @Orm\ManyToOne(targetEntity="OrderEntity")
* @Orm\Cascade(operations={"remove"})
*/
private OrderEntity $order;
}
class OrderEntity {
/**
* @Orm\InverseOf(targetEntity="OrderItemEntity", relation="order")
*/
public EntityCollection $items;
}
$order = $entityManager->find(OrderEntity::class, 123);
$entityManager->remove($order);
$entityManager->flush(); // all OrderItemEntity rows with order_id = 123 are deleted too
@Orm\Cascade(operations={"remove"}) on the @Orm\InverseOf side instead throws a RuntimeException when entity metadata is built: cascade-remove is discovered by querying the dependent entity's foreign key column directly, so it never reads Cascade off InverseOf — only persist is meaningful there.
Combining Cascade Operations
Because persist and remove live on different sides, giving a parent full ownership of its children's lifecycle — the most common composition pattern — takes two separate @Orm\Cascade annotations, one per entity:
class OrderEntity {
/**
* @Orm\InverseOf(targetEntity="OrderItemEntity", relation="order")
* @Orm\Cascade(operations={"persist"})
*/
public EntityCollection $items;
}
class OrderItemEntity {
/**
* @Orm\ManyToOne(targetEntity="OrderEntity")
* @Orm\Cascade(operations={"remove"})
*/
private OrderEntity $order;
}
// persist cascades from the order's InverseOf collection...
$order = new OrderEntity();
$order->items->add(new OrderItemEntity());
$entityManager->persist($order);
$entityManager->flush();
// ...and remove cascades from the item's ManyToOne back-reference
$entityManager->remove($order);
$entityManager->flush();
operations={"persist", "remove"} together on a single annotation is only valid on @Orm\ManyToOne / @Orm\OneToOne, where it means something different again — see "Cascade with ManyToOne" below.
When to Use Cascade
Use cascade persist (on the parent's @Orm\InverseOf collection) when:
- Child entities only exist as part of the parent (composition)
- Creating the parent always creates children (e.g., Order → OrderItems)
- Children have no meaning without the parent
Use cascade remove (on the child's @Orm\ManyToOne/@Orm\OneToOne) when:
- Deleting the parent should delete all children
- Children cannot exist without the parent
- You want to maintain referential integrity at the application level
Don't use cascade when:
- Related entities are independent (e.g., Product → Category)
- Related entities are shared across multiple parents
- You need fine-grained control over which children are persisted or removed
Cascade with ManyToOne
A @Orm\Cascade on a @Orm\ManyToOne / @Orm\OneToOne property can carry persist and remove together, but the two act in opposite directions relative to that property:
- persist — persisting this entity also persists the entity it references. Useful when you build child-first and the parent may not exist yet.
- remove — removing the referenced entity also removes this one. This is the mechanism behind every cascade-remove example on this page.
class OrderItemEntity {
/**
* @Orm\ManyToOne(targetEntity="OrderEntity")
* @Orm\Cascade(operations={"persist", "remove"})
*/
private OrderEntity $order;
}
// persist: creating an item with a brand-new order persists both
$item = new OrderItemEntity();
$item->setOrder(new OrderEntity());
$entityManager->persist($item); // order is persisted automatically
$entityManager->flush();
// remove: deleting the order this item points to removes the item too
$entityManager->remove($item->getOrder());
$entityManager->flush();
Avoid pairing this with cascade persist on the @Orm\InverseOf side of the same relationship, and avoid two @Orm\ManyToOne/@Orm\OneToOne properties that cascade remove back at each other across two different relationships — both create circular cascade chains (see Bidirectional Relationships below).
Important Considerations
Cascade is Application-Level, Not Database-Level
ObjectQuel cascade runs in PHP, not as a database constraint. This has practical consequences:
- ObjectQuel loads related entities into memory before deleting them
- Lifecycle events (
@PreDelete,@PostDelete) fire for each cascaded delete - You have full visibility over what is being deleted
- Database foreign key constraints are not required, but can be declared independently with
@Orm\ForeignKey/@Orm\ForeignKeyAction— see Foreign Key Constraints
Performance Impact
Cascade remove queries the dependent table directly by foreign key, then issues one delete per matching row. Deleting a parent with a large collection can be slow:
// Removing an order with 100 items triggers:
// 1. SELECT to load the order
// 2. SELECT to find all 100 items whose order_id matches
// 3. 100 individual DELETE queries (one per item)
// 4. DELETE for the order itself
$order = $entityManager->find(OrderEntity::class, 123);
$entityManager->remove($order);
$entityManager->flush();
For large collections, consider batch delete queries or delegating removal to a database-level ON DELETE CASCADE foreign key constraint instead — see Foreign Key Constraints.
Bidirectional Relationships
Cascade-remove can never be declared on both the parent's @Orm\InverseOf collection and the child's @Orm\ManyToOne — the metadata builder rejects remove on @Orm\InverseOf outright, so that classic mistake simply isn't possible here. The remaining risk is two entities that each hold a @Orm\ManyToOne/@Orm\OneToOne back-reference to the other, both with cascade remove:
// DANGEROUS — circular cascade
class OrderEntity {
/**
* @Orm\ManyToOne(targetEntity="InvoiceEntity")
* @Orm\Cascade(operations={"remove"}) // removing the invoice removes the order
*/
private InvoiceEntity $invoice;
}
class InvoiceEntity {
/**
* @Orm\ManyToOne(targetEntity="OrderEntity")
* @Orm\Cascade(operations={"remove"}) // removing the order removes the invoice
*/
private OrderEntity $order;
}
ObjectQuel guards against infinite recursion — an entity already scheduled for deletion is never processed a second time — but a cycle like this can still cascade the delete across far more of the graph than intended.
As a rule, cascade remove in one direction only — from a stable parent down to genuinely dependent children.
Examples
Blog Post with Comments
class CommentEntity {
/**
* @Orm\ManyToOne(targetEntity="PostEntity")
* @Orm\Cascade(operations={"remove"})
*/
private PostEntity $post;
}
class PostEntity {
/**
* @Orm\InverseOf(targetEntity="CommentEntity", relation="post")
*/
public EntityCollection $comments;
}
// Deleting the post deletes all its comments
$post = $entityManager->find(PostEntity::class, 1);
$entityManager->remove($post);
$entityManager->flush();
Shopping Cart with Items
class CartItemEntity {
/**
* @Orm\ManyToOne(targetEntity="CartEntity")
* @Orm\Cascade(operations={"remove"})
*/
private CartEntity $cart;
}
class CartEntity {
/**
* @Orm\InverseOf(targetEntity="CartItemEntity", relation="cart")
* @Orm\Cascade(operations={"persist"})
*/
public EntityCollection $items;
}
// New items are persisted together with the cart...
$cart = new CartEntity();
$cart->items->add(new CartItemEntity());
$cart->items->add(new CartItemEntity());
$entityManager->persist($cart);
$entityManager->flush();
// ...and removing the cart removes its items
$entityManager->remove($cart);
$entityManager->flush();
Best Practices
- Cascade persist belongs on the parent's
@Orm\InverseOfcollection; cascade remove belongs on the child's@Orm\ManyToOne/@Orm\OneToOneback-reference — they are never the same property - Use cascade for composition relationships where one entity owns another's lifecycle
- Avoid cascade for association relationships between independent entities
- Treat cascade remove as a destructive operation — test it carefully before using in production
- Document cascade behavior in entity classes so it is visible without reading annotations
- Avoid two entities cascading remove back at each other — keep cascade remove flowing in one direction, parent to child
- For large collections, benchmark cascade remove and consider database-level alternatives (
@Orm\ForeignKeyAction(onDelete="CASCADE"))