sofa/eloquent-cascade
Sofa/eloquent-cascade adds cascading delete and update behavior to Laravel Eloquent models, letting you propagate changes through related records automatically. Configure cascades on relationships to keep parent and child data consistent without manual cleanup code.
1. Install and pin. The package has had no release since April 2021, so install it knowingly:
composer require sofa/eloquent-cascade
Before writing code, open vendor/sofa/eloquent-cascade/composer.json and confirm the illuminate/* constraint against your Laravel version. On Laravel 9+ you may need --ignore-platform-reqs, a fork, or to abandon it (see Gotchas).
2. Where to look first. There is no service provider to publish and no config file — the whole surface area is a trait plus a couple of protected properties on your models. Read the trait source (src/ in the vendor dir) top to bottom; it is small. Look specifically at which methods it overrides (delete(), forceDelete(), and any restore hook) and how it walks the declared relations. That, not the README, is the contract.
3. First use case. Add the trait to the parent model and list relation method names (not table names, not class names):
use Sofa\Eloquence\Cascade\CascadeSoftDeletes;
use Illuminate\Database\Eloquent\SoftDeletes;
class User extends Model
{
use SoftDeletes, CascadeSoftDeletes;
// relation methods on this model that should be cascaded
protected $cascadeDeletes = ['posts', 'comments'];
}
Each child must itself use SoftDeletes for a soft cascade to work, and must also use the cascade trait if you want the cascade to continue downward.
4. Prove it in a test immediately. Do not trust a manual delete through a controller:
$user = User::factory()->hasPosts(3)->create();
$user->delete();
$this->assertSoftDeleted('users', ['id' => $user->id]);
$this->assertSame(0, Post::where('user_id', $user->id)->count());
$this->assertSame(3, Post::withTrashed()->where('user_id', $user->id)->count());
If that test passes, you know the trait is actually wired into your model — that is the highest-value 10 minutes you will spend with this package.
Model-level, not controller-level. Treat cascading as a data-integrity invariant. $user->delete() anywhere in the app — an artisan command, a queued job, a Nova action — produces the same result. Do not implement cascades in controllers; you will forget one path and orphan rows.
Wrap multi-row cascades in a transaction. The package deletes children one relation at a time; a failure partway through leaves the parent soft-deleted and some children untouched.
DB::transaction(function () use ($user) {
$user->delete();
});
Multi-level trees. Each intermediate model needs the trait and its own list:
class Post extends Model
{
use SoftDeletes, CascadeSoftDeletes;
protected $cascadeDeletes = ['comments'];
}
Deleting the User then reaches Comment. Verify with a nested assertion — depth-two bugs are common because developers add the trait only to the root.
Hard deletes alongside soft deletes. Decide per model whether forceDelete() should cascade. A parent force-deleted while children are only soft-deleted leaves rows pointing at a missing user_id. Either cascade the force delete, or rely on real FK constraints with onDelete('cascade') — but not both for the same relation, or you get redundant work and confusing event ordering.
Restore is a separate problem. Cascading restores is convenient but order-sensitive if children have unique indexes or FKs. If the version you install supports it, test the restore path as carefully as the delete path; if it does not, write an explicit restore() wrapper on the parent that restores children in dependency order, inside a transaction.
Pivot / many-to-many. belongsToMany rows are not "deleted", they are detached. The package cascades model deletes; detaching pivot rows is your job. Handle it in a deleting observer on the parent:
static::deleting(function ($user) {
$user->roles()->detach();
});
Large datasets. A cascade over a parent with 100k children will issue a query per model (children fire their own events). If the child has no observers you care about, consider a one-off Post::where('user_id', $id)->delete() (which does not fire per-model events — see Gotchas) or chunk the cascade in a job.
Testing pattern. Use RefreshDatabase and assert both the parent and the deepest child. Add a regression test asserting that a mass delete path you rely on does not silently skip the cascade.
User::where(...)->delete() and DB::table('users')->delete() do not instantiate models, so no events fire and no cascade runs. Always call delete() on a model instance when you need cascading. This is the single most common production surprise with this package.$cascadeDeletes entries must be methods that return a Relation. A typo, or listing a column, typically fails quietly or throws an unrelated error deep in the trait. Add a test per relation you list.User → Post → User will loop if both sides declare the cascade. Guard the downward traversal or keep cascades strictly directional.delete() inside a deleting observer on a model that is already part of a cascade — you will re-enter the trait and double-fire events. Use deleted for post-processing (files, search index) and keep deleting free of further deletes.posts has unique(slug) and you soft-delete a post, recreating it collides. Standard fix: include deleted_at in the index or null it out on delete — decide this before you cascade anything.app/Support/ is a legitimate strategy and removes an unsupported dependency.->cascadeOnDelete() / ->nullOnDelete() migration helpers cover hard-delete FK cascades with no PHP code, and michaeldyrynda/laravel-cascade-soft-deletes is the actively maintained equivalent for soft deletes. This package's only edge is soft-delete cascading; if you do not need that, skip it.Model::booted() observers, DB::listen), (b) confirm the child uses SoftDeletes and has a deleted_at column, (c) confirm the relation name resolves — dd($model->posts()) should return a Relation, not a Collection, (d) log inside deleting/deleted to see whether events fire at all.How can I help you explore Laravel packages today?