iatstuti/laravel-cascade-soft-deletes
Laravel Cascade Soft Deletes adds an Eloquent trait that automatically cascades soft deletes, restores, and force deletes to related models, keeping parent and child records consistent without custom model event code.
composer require iatstuti/laravel-cascade-soft-deletes
Add the trait to the parent model alongside SoftDeletes and declare the relations to cascade:
use Iatstuti\Database\Support\CascadeSoftDeletes;
use Illuminate\Database\Eloquent\SoftDeletes;
class User extends Model
{
use SoftDeletes, CascadeSoftDeletes;
protected $cascadeSoftDeletes = ['posts', 'posts.comments'];
}
Now $user->delete() soft-deletes the user and every related post and comment.
Where to look first
README.md — it documents the two supported properties ($cascadeSoftDeletes, $restoreCascadeSoftDeletes in newer versions) and the overridable hooks.src/CascadeSoftDeletes.php — small file; read it once so you know exactly when the cascade fires.CHANGELOG — restore and force-delete behaviour has changed across major versions, so pin a version and check its notes.First use case: a User with Post children and Comment grandchildren. Verify with a Tinker session ($user->delete(); User::withTrashed()->find(...)->posts()->withTrashed()->get()) before wiring it into a controller.
Recursive cascading — pick one strategy
Either list a nested path ('posts.comments') on the parent, or put the trait on the intermediate model with protected $cascadeSoftDeletes = ['comments'] and only cascade 'posts' from User. Prefer the latter for anything more than two levels: it keeps ownership of each cascade with the model that owns the relation.
Restore
Restoring is not automatic in every version. The safe pattern is to wire it explicitly:
public function restore()
{
$this->restoreCascadeSoftDeletes();
parent::restore();
}
Only relations declared in $cascadeSoftDeletes (or $restoreCascadeSoftDeletes if your version has it) are restored.
Wrap the call in a transaction
DB::transaction(fn () => $user->delete());
The trait issues one query per relation. If any relation fails, you get a half-deleted tree otherwise.
Queue big cascades
For a user with 50k comments, a synchronous cascade is one query loading every row into memory. Move the delete into a queued job:
DeleteUserJob::dispatch($user);
and perform the delete() inside the job, so the HTTP request isn't blocked and retries are handled.
Observer hook for auditing
The parent's deleting event fires before the cascade. The children's deleting/deleted events fire individually, so a single AuditObserver on each model captures everything without special-casing.
Test helper
public function test_deletes_cascade(): void
{
$user = User::factory()->hasPosts(3)->create();
$user->delete();
$this->assertSoftDeleted($user);
$this->assertSoftDeleted($user->posts->first());
}
User::where(...)->delete() does not fire model events, so nothing is cascaded. Use ->each->delete() (or ->chunkById(...)->each) so the trait runs.BelongsTo relations are dangerous. $cascadeSoftDeletes = ['account'] will soft-delete the parent account when you delete a user. Only put HasMany / HasOne / BelongsToMany entries in there.SoftDeletes. If the child model doesn't, delete() hard-deletes it — likely not what you want, and irreversible.restoreCascadeSoftDeletes() exists, it restores rows the cascade originally touched, but it can also restore children that were soft-deleted independently. Don't rely on restore for "undo everything" semantics.deleted_at and relations. Child relations that already contain trashed rows are correctly skipped (the global scope filters them), but a relation defined with withTrashed() will pull already-deleted rows back into the delete set. Avoid withTrashed() in cascade relations.$user->forceDelete() cascades a hard delete to children when the trait is present. To keep soft-delete behaviour on children, override forceDeleting() to return false on the parent, or omit those relations from the cascade and clean up separately with a scheduled prune.hasOne()->where(...) returned as a single model, or ->pluck() chains won't work — the trait calls the relation method and iterates the query builder.ray($this->getCascadeSoftDeletes()) / dd($model->cascadeSoftDeletes ?? 'not set') inside the deleting listener. The two most common causes are a typo in the relation name and the relation returning null for one parent instance.deleting listener. If you need conditional cascading (e.g. skip cascading for admin-owned records), override cascadeSoftDeletes() in the model rather than editing the trait.composer.json and read the changelog on upgrade — 0.x is iatstuti, but the package was previously michaeldyrynda/laravel-cascade-soft-deletes on GitHub, so composer/GitHub names diverge.How can I help you explore Laravel packages today?