dyrynda/laravel-cascade-soft-deletes
Adds cascading soft deletes to Laravel Eloquent models via a simple trait. When a parent model is soft deleted or restored, its related models are soft deleted or restored too, keeping data consistent without extra observers or manual event wiring.
dyrynda/laravel-cascade-soft-deletes is a single-trait package: no service provider, no config file, no migrations. It hooks Eloquent's deleting/restoring events and walks a list of relations you declare on the model.
composer require dyrynda/laravel-cascade-soft-deletes
use Dyrynda\Database\Support\CascadesSoftDeletes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class User extends Model
{
use SoftDeletes, CascadesSoftDeletes;
protected $cascadeDeletes = ['posts'];
public function posts()
{
return $this->hasMany(Post::class);
}
}
$user->delete(); // user + every post get deleted_at
$user->restore(); // user + every post are restored
Where to look first: the trait source (src/CascadesSoftDeletes.php) is short enough to read end-to-end in a few minutes, followed by the two property names it honours — $cascadeDeletes (soft delete/restore cascade) and $forceDeleting (force-delete cascade). The first real use case is almost always the tenant/user tree: deleting an account or organisation should soft-delete its projects, posts, comments, and attachments without sprinkling $model->posts()->delete() calls through controllers.
Declare the cascade on the parent only. Children need the trait only if they cascade further. A comment does not need CascadesSoftDeletes just because Post cascades to it.
Multi-level trees recurse for free. Because the package soft-deletes each child through $child->delete(), the child's own deleting listeners fire, including its own cascade:
class Post extends Model
{
use SoftDeletes, CascadesSoftDeletes;
protected $cascadeDeletes = ['comments'];
}
Deleting the User now soft-deletes Posts, which soft-delete Comments.
Symmetry between delete and restore is the main selling point. restore() walks the same relation list, so an "undo delete" button is a one-liner. This is the pattern teams actually buy the package for — it is far more valuable than the delete side.
Force deletes are opt-in and separate. Declare the relations you want physically removed when the parent is force-deleted:
protected $cascadeDeletes = ['posts'];
protected $forceDeleting = ['posts'];
Keep the two lists aligned; a relation present in $cascadeDeletes but not $forceDeleting leaves orphaned trashed rows behind after a hard purge.
Keep it invisible to callers. Controllers, form requests, and policies should still just authorise and call $model->delete(). Don't leak cascadeDelete() calls into application code; the trait's contract is that delete() means "delete the aggregate".
Wrap in a transaction when there are side effects. The cascade issues one UPDATE per model. If an observer queues jobs, hits S3, or writes an audit row, wrap the parent delete in DB::transaction(...) so a mid-tree failure doesn't leave a half-deleted aggregate.
Extension point: the trait's cascade methods are plain public methods — override them in the model (calling parent::) to add logging, dispatch a PostCascadeDeleted event, or swap in a queued job for very large trees. That's the cleanest place to hook custom behaviour rather than adding yet another deleting listener.
Test at the boundary, not the internals. assertSoftDeleted($post) after $user->delete() and assertNotSoftDeleted($post) after $user->restore() document the behaviour without coupling tests to the trait.
User::where(...)->delete() never hydrates a model and never fires deleting, so nothing cascades. Use ->get()->each->delete() or ->chunkById(...) if you need the trait to run.withoutEvents(), deleteQuietly(), and withTrashed()->where(...)->restore(). Restoring via the query builder skips restoring, so children stay trashed.SoftDeletes on the model. It calls isForceDeleting(), which lives on that trait.onDelete('cascade') is irrelevant here. The row is never removed, so the database constraint never fires. Don't assume your schema is protecting you.SoftDeletes gets hard-deleted. The package calls delete() on the relation; if the model has no deleted_at column, that is a permanent DELETE. Verify every model in $cascadeDeletes is soft-deletable.deleting listeners returns false after the cascade listener has run, the parent survives while the children are already trashed. Register guard listeners early, or validate before calling delete().belongsToMany is a footgun. Calling delete() through a many-to-many relation will remove the related rows but leave pivot entries behind. Handle those manually with ->detach() in an observer.deleting, updating, deleted, plus broadcasts and search-index sync). Deleting a 50k-row subtree in a request will time out — move it to a job that deletes root children in chunks.parent/children tree where a child also lists the parent in $cascadeDeletes will loop. Keep cascades strictly downward.$cascadeDeletes throws BadMethodCallException during the delete, not at boot. Add a cheap test that deletes a fixture parent and asserts each child is trashed.ray($relation) or DB::listen(...) inside the trait, or attach an extra deleting listener to confirm ordering. The whole package is one file; a dd() in cascadeDelete() tells you exactly what was walked.How can I help you explore Laravel packages today?