Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Cascade Soft Deletes Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Query-builder deletes bypass everything. 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.
  • The same applies to withoutEvents(), deleteQuietly(), and withTrashed()->where(...)->restore(). Restoring via the query builder skips restoring, so children stay trashed.
  • The trait requires SoftDeletes on the model. It calls isForceDeleting(), which lives on that trait.
  • Foreign-key onDelete('cascade') is irrelevant here. The row is never removed, so the database constraint never fires. Don't assume your schema is protecting you.
  • A child that doesn't use 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.
  • Restore overshoots. Restoring the parent restores all soft-deleted rows on that relation, including ones deleted independently weeks earlier. The package does not track "which children this parent took down". If that matters, record it yourself or gate restore behind a confirmation.
  • Aborting a delete mid-flight is unsafe. If one of your 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.
  • No batching, no chunking. Each model is deleted individually and fires its own events (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.
  • Circular relations can recurse indefinitely. A self-referencing parent/children tree where a child also lists the parent in $cascadeDeletes will loop. Keep cascades strictly downward.
  • Typos surface at the worst moment. A misnamed relation in $cascadeDeletes throws BadMethodCallException during the delete, not at boot. Add a cheap test that deletes a fixture parent and asserts each child is trashed.
  • Debugging. Temporarily add 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox