Cascading

Cascade operations automatically propagate persist and remove operations from a parent entity to its related children, reducing boilerplate and keeping related entities synchronized.

explanation

What are Cascade Operations?

Without cascade, you must explicitly persist every entity you want saved. With cascade configured on a relationship, ObjectQuel handles children automatically when you act on the parent:

class OrderEntity {
    /**
     * @Orm\OneToMany(targetEntity="OrderItemEntity", mappedBy="orderId")
     * @Orm\Cascade(operations={"persist", "remove"})
     */
    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();

Cascade Persist

When cascade persist is set, any child entity added to the 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\OneToMany(targetEntity="OrderItemEntity", mappedBy="orderId")
     * @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

When cascade remove is set, deleting the parent automatically deletes all children in the collection. Without this, you would need to remove each child manually before removing the parent, or rely on database-level foreign key constraints.

class OrderEntity {
    /**
     * @Orm\OneToMany(targetEntity="OrderItemEntity", mappedBy="orderId")
     * @Orm\Cascade(operations={"remove"})
     */
    public EntityCollection $items;
}

$order = $entityManager->find(OrderEntity::class, 123);

$entityManager->remove($order);
$entityManager->flush();  // all associated OrderItems are deleted too

Combining Cascade Operations

You can specify both persist and remove together. This is the most common configuration for composition relationships, where the parent fully owns the children's lifecycle:

class OrderEntity {
    /**
     * @Orm\OneToMany(targetEntity="OrderItemEntity", mappedBy="orderId")
     * @Orm\Cascade(operations={"persist", "remove"})
     */
    public EntityCollection $items;
}

// persist cascades on save...
$order = new OrderEntity();
$order->items->add(new OrderItemEntity());
$entityManager->persist($order);
$entityManager->flush();

// ...and remove cascades on delete
$entityManager->remove($order);
$entityManager->flush();

When to Use Cascade

Use cascade persist 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 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

Cascade can also be applied to ManyToOne relationships, though this is uncommon. It makes the child entity responsible for persisting the parent — useful when you are building child-first and the parent may not yet exist:

class OrderItemEntity {
    /**
     * @Orm\ManyToOne(targetEntity="OrderEntity")
     * @Orm\Cascade(operations={"persist"})
     */
    private OrderEntity $order;
}

// The order is created inline and persisted automatically via the item
$item = new OrderItemEntity();
$item->setOrder(new OrderEntity());

$entityManager->persist($item);  // order is persisted automatically
$entityManager->flush();

Avoid combining this with cascade on the parent side, as it creates 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 added independently

Performance Impact

Because cascade remove operates at the application level, it generates a query per child entity. 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 load all 100 items
// 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.

Bidirectional Relationships

Never configure cascade remove on both sides of a bidirectional relationship. If the parent cascades remove to children, and a child cascades remove back to the parent, ObjectQuel will enter a recursive loop:

// DANGEROUS — circular cascade
class OrderEntity {
    /**
     * @Orm\OneToMany(targetEntity="OrderItemEntity", mappedBy="orderId")
     * @Orm\Cascade(operations={"persist", "remove"})
     */
    public EntityCollection $items;
}

class OrderItemEntity {
    /**
     * @Orm\ManyToOne(targetEntity="OrderEntity")
     * @Orm\Cascade(operations={"remove"})  // triggers cascade back to OrderEntity
     */
    private OrderEntity $order;
}

As a rule, cascade in one direction only — from parent to children.

Examples

Blog Post with Comments

class PostEntity {
    /**
     * @Orm\OneToMany(targetEntity="CommentEntity", mappedBy="postId")
     * @Orm\Cascade(operations={"remove"})
     */
    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 CartEntity {
    /**
     * @Orm\OneToMany(targetEntity="CartItemEntity", mappedBy="cartId")
     * @Orm\Cascade(operations={"persist", "remove"})
     */
    public EntityCollection $items;
}

// Items are persisted and removed together with the cart
$cart = new CartEntity();
$cart->items->add(new CartItemEntity());
$cart->items->add(new CartItemEntity());

$entityManager->persist($cart);
$entityManager->flush();

Best Practices

  • Use cascade for composition relationships where the parent owns the children'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
  • Cascade in one direction only — parent to children, not both ways
  • For large collections, benchmark cascade remove and consider database-level alternatives