Skip to content
Go back

Why Foreign Key Cascades Don't Work With Soft Deletes (And What To Do Instead)

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 (SoftDeletes trait), which means the row is never actually removed from the ideas table — it just sets a deleted_at timestamp. Because the row stays in the database, the FK cascade on shared_links.idea_id never 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

CriteriaBoot methodPackageDitch soft deletes
Lines of code~5~3 (config only)Large refactor
DependenciesNoneOne packageOne package
Handles forceDelete?Need to check isForceDeleting()Yes, automaticallyN/A — no soft deletes
Handles mass deletes?NoNoYes (database-level)
Best forMost appsMany models needing cascadeNew 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:

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 deleting and deleted model 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.


Back to top ↑