Relationship lifecycle
Available since v1.5.0: The explicit lifecycle policies and service described here were introduced in HectorORM PR #148.
See Relationships for relationship declarations, loading and query options.
Cardinality, dependency direction and lifecycle are separate concerns. A child relationship does not, by its name alone, authorize deleting the target entity.
End-to-end workflow
Using the User/Profile child mapping, the usual entry point is still a normal entity save:
$user = User::find(1);
$replacement = new Profile();
$replacement->bio = 'Updated profile';
$user->profile = $replacement;
$user->save(cascade: true);
The lifecycle service participates automatically when the materialized graph contains a lifecycle relation, such as
HasMany, HasOneChild or a OneToOne with a resolved direction. Applications can also open an
explicit lifecycle transaction.
From assignment to commit
- Record the intent. Assignment changes the cached relation without writing SQL.
Relatedrecords whether the old child was loaded, its previous value when known, and removed intermediate assignments. Collections track explicit removals in their detached list. Query hydration is not an assignment. - Enter the lifecycle context. The service captures the known graph and opens a transaction, or a savepoint inside a caller-owned transaction. Additional affected objects are tracked before the ORM mutates them. Nested saves share the same lifecycle context.
- Persist in dependency order. For a parent-side relation, save the parent first. For a new parent, obtain its generated identifier before linking the child. A child-side reference saves its parent as needed before writing the child.
- Apply relationship changes. Resolve an unloaded previous scalar child through the declared view. Detach or delete
it according to the removal policy, then propagate parent keys and save the replacement. Releasing
the old link first allows a replacement under a unique FK constraint. Existing related non-link changes are saved when
cascade: trueis requested, within the same transaction. - Finish successfully. Clear processed assignment/removal tracking and commit the transaction. For a caller-owned transaction, release the savepoint; the caller still controls the outer commit. Rollback snapshots remain available until the operation has successfully finished.
For a new parent and child, the write order is:
INSERT parent
-> retrieve parent identifier
-> assign identifier to child linking columns
-> INSERT child
-> commit
If a write fails
The failure propagates to the lifecycle boundary, which rolls back SQL and restores the captured in-memory state. For the replacement example above:
- The old profile exists again in the database if its removal had already been executed.
- Generated identifiers, linking values, original ORM data and storage statuses revert to their captured values.
$user->profilestill references$replacement, because that was the desired value beforesave()started.- The assignment journal again describes the pending replacement, so correcting the failure and calling
$user->save()again can retry the operation.
Snapshots restore the state at capture time. Changes made after that capture inside an explicit transaction callback may therefore be undone. See Atomic writes and rollback for the transaction boundaries and the distinction from PHP serialization.
Deferred writes
$user = new User();
$user->name = 'Example';
$profile = new Profile();
$profile->bio = 'Hello';
$user->profile = $profile;
$orm->save($profile); // Queue the child first, without writing it yet.
$orm->save($user);
$orm->persist();
For a lifecycle batch, the service captures pending state, cancels never-persisted children already removed from their
relations, and starts with the materialized parent roots. Their linking operations persist descendants in dependency
order. A persist() inside an active lifecycle transaction joins that context.
The dependency comes from the declared, materialized relationship graph: queuing an unattached child alone does not identify a parent for it.
Responsibilities
| Component | Responsibility |
|---|---|
| Relationships | Column mappings, dependency direction and identification of affected children. |
Related and collections |
Current relation values and explicit assignment/removal intent. |
Lifecycle |
Apply lifecycle policies and orchestrate the operation. |
LifecycleTransaction |
Database transaction/savepoint, snapshots and rollback restoration. |
Orm and mappers |
Schedule entities and execute their inserts, updates and deletes. |
Parent-child policy
Configure orphanRemoval on HasMany or HasOneChild, or on their programmatic declarations. Both parent-side scalar
and collection relationships use the same Lifecycle service:
use Hector\Orm\Attributes as Orm;
#[Orm\HasMany(
target: OrderLine::class,
name: 'lines',
columns: ['id' => 'order_id'],
orphanRemoval: true,
)]
| Policy | Explicitly removed child |
|---|---|
false |
Clear its linking columns and save it. Required linking columns or primary-key columns prevent detachment and produce a RelationException. |
true |
Delete it through the ORM. |
Omitted / null on HasMany (before v2) |
Preserve historical deletion of detached children. |
Omitted / null on HasOneChild |
Default to false: detach rather than delete. |
The omitted-policy default on HasMany is a deprecated compatibility behavior. Set orphanRemoval: true explicitly to
preserve deletion when upgrading to v2. The v2 target default is false for both parent-side relation types.
See HasOneChild for the scalar relation introduced by issue #135.
Explicit scalar changes
$user->profile = null; // Detach or delete the previous profile according to its policy.
$user->save();
$user->profile = new Profile(); // A replacement releases the previous unique link first.
$user->profile->bio = 'New';
$user->save();
An explicit scalar assignment resolves the previous child when needed, even if it was never loaded. This differs from collection replacement: a scalar relation describes at most one child, whereas an unloaded collection may contain arbitrarily many unseen members.
The lookup respects the declared relation view. A hidden child is preserved; its unique FK may then prevent insertion of a replacement. Reading an absent/filtered-out child alone never schedules removal. Reassigning the same persisted child does not delete it, and replacing an intermediate unsaved assignment does not delete an unrelated persisted entity that was never linked by that assignment.
Failed persistence restores the old child state and pending assignment for retry. Invalidating the cache explicitly with
getRelated()->unset('profile') also discards that pending assignment without changing database rows.
Explicit collection changes
$lines = $order->lines;
unset($lines[0]); // Explicit removal, processed when saving the order.
$order->save();
Replacing an offset also removes its previous entity. Removing and then reattaching the same entity before saving does not delete it. A transient child removed before persistence produces no SQL DELETE; a pending insert for that removed child is cancelled when it has not already been persisted.
When the policy is explicitly configured, assigning another collection replaces the previously materialized members:
use Hector\Orm\Collection\Collection;
$order->lines; // Materialize the relation.
$order->lines = new Collection([$kept]);
$order->save(); // Process known members absent from the new collection.
- Only previously materialized members and explicitly tracked removals are candidates for removal.
- Assigning an empty collection to an unloaded relation does not clear unseen database rows.
- Replacing a filtered or limited collection never removes rows outside the loaded view.
- Assigning
nullwith an explicit policy is equivalent to assigning an empty collection, with the same rules. - Without an explicit policy, pre-v2 whole-collection assignment keeps its historical behavior (no inferred difference).
- Query hydration is not user replacement and never creates removal records.
getRelated()->unset('lines')only invalidates the cache. Save pending removals before discarding their collection.
This is deliberately not a database-wide collection synchronization API. Query filters, grouping and limits do not define ownership of all matching or missing rows. To remove children, load the intended entities and explicitly remove them.
For existing linked children whose non-link fields change, continue to use save(cascade: true).
Other relation types
A parent reference (ManyToOne, currently exposed by HasOne) cannot apply orphanRemoval to its parent. A many-to-many
relationship removes pivot links, not the shared target entities. Passing a non-null orphanRemoval option to unsupported
relationships throws a RelationException; it is not silently ignored.
Inverting a relationship does not copy its deletion policy to the opposite direction.
Inverse declarations
Available since v1.5.0: Introduced by issue #145.
InverseOf reuses relationship structure, not destructive lifecycle policies. A resulting parent-side OneToMany or
OneToOne defaults to orphanRemoval: false; explicit true enables deletion of removed children. Child-to-parent
references reject unsupported lifecycle options. A pivot inverse removes pivot links only.
Before v2, deprecated collection-valued BelongsTo declarations retain their historical detached-child deletion policy.
When migrating these declarations to InverseOf, specify foreignName and orphanRemoval: true if preserving that policy
is intentional. Otherwise use the new non-destructive default. Filters remain read views, not ownership rules or assigned
persistence values. See the migration examples.
Atomic writes and rollback
Orm::lifecycle() exposes the Hector\Orm\Lifecycle service associated with that ORM instance. It orchestrates child
detachment/removal, change tracking and the active transaction context. Entity saves invoke it automatically for
materialized lifecycle graphs.
The underlying Storage\LifecycleTransaction handles snapshots and database transaction/savepoint mechanics.
Transaction snapshots and PHP serialization
Related, ORM Collection and EntityData implement the internal
Hector\Orm\Storage\LifecycleSnapshotInterface contract:
lifecycleSnapshot()captures the state owned by the participant, including pending changes needed for rollback.restoreLifecycleSnapshot()restores that state on the same instance without issuing SQL or scheduling new mutations.
The transaction uses this contract rather than calling __serialize() or __unserialize(). Snapshot arrays may retain
object references; they are process-local rollback state, not a transport or cache format. Mapped entity properties and
storage statuses are also captured by the transaction.
PHP serialization of Related keeps its existing related payload. The new scalar assignment journal (assignments)
is deliberately excluded and is empty after unserialization; serializing an entity does not clear the journal on the
original live object. A native serialization round-trip preserves cached relation values, but does not transfer a complete
ORM unit of work. Reload entities in the receiving ORM and explicitly reapply intended assignments before persisting them.
Explicit lifecycle transactions
An explicit operation can use the same service:
$orm->lifecycle()->transaction($order, function () use ($order): void {
$order->save();
// Additional ORM writes on the same connection share the lifecycle context.
});
The callback result is returned. Nested calls join the active context; their exceptions must propagate to its boundary
to roll back the complete operation. The service resets its active context on both success and failure. Its track(),
linkChild(), removeChild(), cancelPendingInsert() and persistBatch() methods are internal integration points for the ORM
and relationships. A persist() called inside this service joins the active lifecycle transaction and tracks its
pending entities, even when they have no loaded child relationship themselves.
Saving a materialized graph containing a parent-child lifecycle relation runs its linking/removal writes and the parent
save in one transaction. Direct OneToMany::linkNative() calls also protect their child operations. Removed links are
released before new children are saved, allowing replacements under unique constraints.
For save(cascade: true), related saves remain within that same lifecycle transaction: an invalid field on an existing
child rolls back earlier orphan removals as well. Batch preparation cancels never-persisted removed children before any
queued insertion, including when the child was scheduled before its parent.
On failure, the transaction restores the captured mapped properties (including generated identifiers and uninitialized typed properties), original ORM data, entity statuses, relation caches and collection removal tracking. Pending user edits and the desired replacement remain available for correction and retry. Custom non-mapped state or external side effects of event listeners are not undone. Snapshots preserve property values/references; they do not deep-copy arbitrary mutable user objects. After-save/delete events are not after-commit notifications. A vetoed child removal or save aborts the lifecycle operation rather than silently discarding removal tracking. A listener that restores a detached FK or redirects a newly attached child to another parent also aborts the operation.
The transaction scope is one connection. Cross-connection materialized graphs are rejected before their captured
entities are written; this feature does not provide distributed transactions. persist() batches containing lifecycle
relations likewise require a single connection and retain snapshots until the complete batch succeeds. SQL writes
performed outside the ORM cannot be tracked in memory.
When a caller already owns a transaction, a savepoint isolates a failing lifecycle operation. A successful savepoint release is not a commit of the outer transaction. If the caller subsequently rolls back that outer transaction, discard or reload the affected entity graph before reuse, as with other external database changes. Use a transactional storage engine with savepoint support (for example InnoDB or SQLite); do not execute implicitly committing DDL inside callbacks.
Transactions do not implement optimistic versioning of relationship assignments. Applications performing concurrent reparenting must coordinate their updates/locking. Keep FK and uniqueness constraints in the database.
Parent deletion
orphanRemoval handles removal from an association, not deletion of its parent. Parent deletion remains governed by SQL
FK actions (CASCADE, SET NULL, RESTRICT) or explicit ORM deletes. SQL cascades do not dispatch child ORM events.
Migration checklist
- Declare
orphanRemoval: trueon existingHasManyrelations that intentionally delete removed children. - Use
falsefor detachable children; ensure all linking columns are nullable and are not primary-key columns. - Review whole-collection assignments when opting in: known missing members now follow the declared policy.
- Keep many-to-many target deletion separate from pivot removal.
- Retain explicit FK actions for parent deletion and uniqueness constraints for one-to-one associations.
Follow #146 for this foundation and #147 for the v2 transition.