fish/eloquent-cascade
A Laravel package for adding configurable cascade behavior to Eloquent models, helping automatically delete or update related records when a parent model changes. Define cascades in PHP to keep data consistent alongside database foreign keys.
Install via Composer:
composer require fish/eloquent-cascade
Laravel’s package auto-discovery will register the service provider (if one is shipped) — for a trait-only package there’s nothing to publish and no config to edit. Open vendor/fish/eloquent-cascade/src/ first: the entire package is essentially one trait. Confirm the trait’s fully-qualified namespace there before writing code, then check the README for the exact property name it reads.
The first use case is a parent with dependent children you want removed in the same request:
use Matfish\Cascade\CascadeDeletes; // confirm namespace from src/
class Post extends Model
{
use CascadeDeletes;
protected $cascade = ['comments']; // relation method names
}
With that in place, $post->delete() also deletes the related comments, without you writing a deleting observer by hand. Verify with a feature test that asserts both rows are gone — this package is small enough that you should treat a passing test as your contract, not the docs.
Declare relations, not tables. List Eloquent relation method names (comments, attachments, tags) rather than table names. The trait will resolve each relation and delete the loaded/queried models, which means relation scoping and global scopes still apply.
Nested cascades. For a deep tree (Post → Comment → Reaction), put the trait on each model and list its own children. The deletion propagates naturally as each child’s delete() fires its own cascade. This is usually clearer than listing grandchildren on the root.
class Comment extends Model
{
use CascadeDeletes;
protected $cascade = ['reactions'];
}
Pivot relations. For belongsToMany, decide explicitly whether you want detach() semantics or nothing at all. Cascading a many-to-many usually just means clearing the pivot row — the related model (e.g. Tag) should survive. If the trait doesn’t special-case pivots for you, override the deletion behavior or handle it in an observer.
Wrap multi-model writes in a transaction. Cascading deletes touch several tables; a half-completed cascade is a data-integrity bug. Do it at the call site:
DB::transaction(fn () => $post->delete());
Prefer explicit overrides for complex branches. If one relation needs conditional cleanup (e.g. only delete draft comments), override the cascade hook in the model or move that logic into an observer, and keep the trait list to the simple unconditional cases.
Integration with observers/policies. Order matters if you already have a deleting observer that blocks deletion. Let authorization run first (policy delete() check in the controller), then call $model->delete() and let the trait handle the rest. Don’t duplicate the cascade by also adding deleting listeners — you’ll double-delete or hit errors on already-missing rows.
Testing pattern. For each cascading model, assert the parent is gone, children are gone, and unrelated rows survive. Use RefreshDatabase and factories; this catches misnamed relation methods immediately.
Post::where(...)->delete() (query builder) never instantiates models, so no model events and no cascade fire. Use ->get()->each->delete(), chunkById, or move the logic to a DB-level ON DELETE CASCADE if you need bulk performance.SoftDeletes, delete() only sets deleted_at; it does not remove rows. Decide whether that’s intended. For hard cleanup, call forceDelete() deliberately and document it.RESTRICT/NO ACTION foreign keys, the order of deletes matters and child-before-parent is required. The trait generally handles this, but a custom relation or pivot can break ordering. Test against a real MySQL/Postgres schema, not just SQLite.withoutEvents() / a static flag.hasMany can silently load and delete thousands of models one at a time. Check DB::listen or Telescope for the actual query count; for big sets, prefer DB-level cascades or chunked deletes.Log::info() inside the model’s deleting event, or dd($model->{$relation}()->get()) in a tinker session, to confirm the relation method resolves to what you expect. Misnamed relation methods are the #1 cause of “cascade did nothing.”app/Concerns/ and adapt. That removes an external dependency and lets you add pivot handling and recursion guards directly — a reasonable call for something this small and low-traffic.composer.json and re-run your cascade tests after any bump.How can I help you explore Laravel packages today?