ADR-0002: Cascade Deletion & Selective Soft Deletes
Accepted Cross-ProjectDate: 2026-02-11
Context
Both projects had a shared weakness: cascade deletion of child records used query-level ->delete() which bypasses model observers and events. This was identified as an oversight, not a deliberate performance choice.
Additionally, the Issue Tracker requires selective soft deletes on high-value models for audit trail purposes (see Audit Logging) and ISO 27001 compliance.
The Brick Inventory had a specific risk: DeleteStorageOptionAction performed recursive tree deletion without a transaction wrapper.
Decision
Model Declares, Action Executes, Tests Verify
Each model declares which relationships must be cascade-deleted:
class Issue extends Model
{
/** Relations that MUST be deleted when this model is deleted */
public static function cascadeRelations(): array
{
return ['comments', 'timeLogs', 'branchLinks'];
}
}This is a declaration only — no automatic behavior. The delete Action explicitly handles each relationship. Architecture tests verify that every declared relation is handled in the corresponding delete Action.
Selective Soft Deletes (Issue Tracker Only)
Soft deletes apply only to high-value models as determined by data classification. Models NOT soft-deleted follow their parent — when a parent is deleted, children are hard-deleted explicitly by the Action in the correct order.
All Deletions Wrapped in Transactions
Every delete Action wraps its full operation (child deletion + parent deletion + audit logging) in a database transaction. No partial deletions.
Foreign Keys as Guards
Foreign key constraints are safety nets. They throw an error if a parent is deleted without handling children first. ON DELETE CASCADE is explicitly rejected — deletion must be explicit in the Action, not implicit in the database.
Deletion Pattern Examples
Issue Tracker — Soft-Deletable Parent (Issue):
DeleteIssueAction:
1. Open transaction
2. Hard-delete comments
3. Hard-delete time logs
4. Hard-delete branch links
5. Soft-delete Issue (set deleted_at)
6. Record audit log
7. Commit transactionBrick Inventory — Hard Delete (StorageOption):
DeleteStorageOptionAction:
1. Open transaction
2. Recursively delete children (depth-first)
3. Hard-delete storage option parts
4. Hard-delete storage option
5. Commit transactionArchitecture Test Enforcement
// All cascade relations are handled in delete actions
it('delete actions handle all declared cascade relations', function () {
$models = getModelsWithCascadeRelations();
foreach ($models as $model) {
$actionSource = getDeleteActionSource($model);
foreach ($model::cascadeRelations() as $relation) {
expect($actionSource)->toContain($relation);
}
}
});
// All HasMany relations are declared in cascadeRelations
it('all HasMany relations are declared in cascadeRelations', function () {
// Reflect on model, verify all HasMany/HasOne are listed
});Options Considered
| Option | Verdict | Reason |
|---|---|---|
ON DELETE CASCADE at database level | Rejected | Implicit behavior — things get deleted without explicit calls. No opportunity to audit or react in PHP. |
Model-level SoftDeletes trait with automatic cascading | Rejected | Implicit cascading behind the scenes. Difficult to debug. |
| Action-level explicit handling with model declarations + tests | Accepted | Actions remain the authority. Models document their children. Tests catch forgotten relationships at CI time. |
Consequences
Positive
- Explicit control over deletion order and behavior
- Architecture tests prevent orphaned data at CI time
- Foreign keys prevent orphaned data at database level
- Transaction wrapping eliminates partial deletion risk
- Pattern is consistent across both projects
Negative
- More verbose delete Actions (must handle each relationship explicitly)
- Two test suites to maintain (cascade relations declared, delete actions complete)
- Developers must update both the model declaration and the action when adding relationships