I was consolidating two database tables into one. The new table had a proper foreign key with cascadeOnDelete() — the gold standard for automatic cleanup. I tested it, deleted an idea, and the related shared link was still sitting in the database.
It took me a while to understand why. The answer is one of those things that’s obvious in hindsight but catches everyone who uses soft deletes.
Table of contents
Open Table of contents
What foreign key cascades do
A foreign key is a rule in your database that says: this column in Table B must point to a real row in Table A. If you try to insert a row that points to something that doesn’t exist, the database rejects it.
cascadeOnDelete() extends that rule: if the parent row in Table A is deleted, automatically delete all the child rows in Table B that reference it. The database handles this instantly and reliably — no PHP code needed.
In a Laravel migration, it looks like this:
$table->foreignId('idea_id')->nullable()->constrained('ideas')->cascadeOnDelete();
This tells MySQL: if an ideas row is deleted, automatically delete any shared_links rows that reference it.
Why it doesn’t work with soft deletes
When you call $idea->delete() on a model that uses Laravel’s SoftDeletes trait, the row is never actually removed. Laravel runs an UPDATE query that sets a deleted_at timestamp:
UPDATE ideas SET deleted_at = '2026-03-08 19:00:00' WHERE id = 1959
The row is still physically in the table. As far as MySQL is concerned, nothing was deleted. The foreign key cascade never fires because the parent row still exists.
Think of it like a filing cabinet. A cascade rule says: if I throw away a folder from Drawer A, automatically shred any folders in Drawer B that reference it. Soft delete doesn’t throw the folder away — it just stamps it “DELETED” and leaves it in the drawer. The cabinet sees the folder is still there, so it never triggers the shredding.
This is the fundamental mismatch: the database thinks in terms of rows existing or not. Laravel’s soft delete changes a column value, but the row still exists.
The question I asked
When I first hit this, I asked:
This doesn’t seem to work? Delete cascade — delete an idea that has a shared link, the corresponding shared_links row is automatically deleted.
After investigating, the issue was clear:
Ideas use soft deletes (
SoftDeletestrait), which means the row is never actually removed from theideastable — it just sets adeleted_attimestamp. Because the row stays in the database, the FK cascade onshared_links.idea_idnever fires.
Three ways to fix it
Option A: Clean up in the model’s boot method
Delete related records explicitly in the deleting event hook:
protected static function booted(): void
{
static::deleting(function ($model) {
$model->sharedLinks()->delete();
$model->stars()->delete();
$model->versions()->delete();
});
}
Pros: Zero dependencies, pure Laravel, logic is visible right inside the model.
Cons: You must remember to add cleanup for every new relationship. Mass deletes like Idea::where(...)->delete() skip model events entirely.
Option B: Use a dedicated package
Michael Dyrynda’s laravel-cascade-soft-deletes package adds a declarative $cascadeDeletes property:
use Dyrynda\Database\Support\CascadeSoftDeletes;
class Idea extends Model
{
use SoftDeletes, CascadeSoftDeletes;
protected $cascadeDeletes = ['sharedLinks', 'stars', 'versions'];
}
Pros: Declarative, supports recursive cascading, handles forceDelete correctly.
Cons: Adds a package dependency, cannot auto-restore children, same mass-delete limitation.
Option C: Ditch soft deletes entirely
Spatie’s laravel-deleted-models package moves deleted records to a single deleted_models table as JSON, so database-level FK cascades work again naturally.
Pros: FK cascades just work, simpler queries, no forgotten withTrashed() bugs.
Cons: A significant paradigm shift for an existing app, you lose Idea::withTrashed() querying.
Which approach to use
| Criteria | Boot method | Package | Ditch soft deletes |
|---|---|---|---|
| Lines of code | ~5 | ~3 (config only) | Large refactor |
| Dependencies | None | One package | One package |
| Handles forceDelete? | Need to check isForceDeleting() | Yes, automatically | N/A — no soft deletes |
| Handles mass deletes? | No | No | Yes (database-level) |
| Best for | Most apps | Many models needing cascade | New projects or major refactors |
For most Laravel apps, Option A (boot method) is the right choice. You’re writing a few lines of explicit cleanup code in the model where the relationships are defined. No magic, no dependencies, and it works for both soft and hard deletes.
What the Laravel community says
Freek Van der Herten (Spatie) takes the most radical position. His blog post argues that soft deletes themselves are the problem. He references Brandur Leach’s influential article Soft Deletion Probably Isn’t Worth It, which argues that in ten-plus years across major companies, soft deletion was never actually used to undelete something.
Michael Dyrynda built the most widely adopted package solution. His key caveat: restoring a parent does NOT auto-restore children, because there’s no way to know which children were cascade-deleted versus already deleted beforehand.
Caleb Porzio wrote the foundational article on bootable model traits that underpins both the manual and package approaches. The key insight: use bootTraitName() instead of boot() in traits, so the parent model’s boot() doesn’t overwrite the trait’s.
What Taylor Otwell says
Taylor has been asked to add cascade soft deletes to Laravel core multiple times. His position is consistent.
When a PR was submitted to add a HasCascadeSoftDeletes trait to Laravel 12.x, Taylor closed it:
I honestly think this is perfect for a package. Why else would packages exist?
An earlier PR in Laravel 5.5 tried to add cascade deletes to the Model class. Taylor closed it too:
Not necessary. Can be done at database level.
His philosophy: the framework provides the hooks (deleting, deleted, trashed, forceDeleting, forceDeleted events), and you use them. If you want something more declarative, use a package. Simple, explicit, no magic.
The fix I applied
My Idea model already cleaned up stars() and versions() in the deleting hook. I just needed to add sharedLinks():
static::deleting(function ($model) {
$model->sharedLinks()->delete();
$model->stars()->delete();
$model->versions()->delete();
});
The FK cascade is still there as a safety net for hard deletes (like when purging a team). So now there are two layers of protection:
- Soft delete — Laravel’s boot hook cleans up related records
- Hard delete — Database FK cascade cleans up related records
Belt and braces.
One gotcha: mass deletes skip model events
The Laravel docs are explicit about this:
When issuing a mass update or delete query via Eloquent, the
deletinganddeletedmodel events will not be dispatched for the affected models. This is because the models are never actually retrieved when executing the delete statement.
This means Idea::where('team_id', 5)->delete() will NOT trigger your cleanup hook. You need to load and delete each model individually:
// This skips model events — children won't be cleaned up
Idea::where('team_id', 5)->delete();
// This fires model events on each idea
Idea::where('team_id', 5)->each(fn ($idea) => $idea->delete());
The second approach is slower (one query per model instead of one bulk query), but it’s the only way to ensure your deleting hook runs.
Key takeaway
Foreign key cascades are a powerful database feature, but they only work when rows are truly deleted. Laravel’s soft deletes update a column instead of removing the row, so the database never knows anything happened. The fix is straightforward: clean up related records in your model’s deleting event hook, and keep the FK cascade as a safety net for hard deletes. A few lines of explicit code beats a false sense of security from a cascade that silently does nothing.