Gracefully Handle Cascade Soft Deletes & Restoration

How to Handle Multi-Level Soft Deletes and Restores Without Data Orphan Bugs

Cascade Soft Deletes & Restoration: Managing Multi-Level Relationships Cleanly


Laravel's SoftDeletes trait allows you to easily soft delete database entries, i.e., mark them as deleted without actually removing them from the database. Doing so marks the deleted_at column of the deleted entry with the current timestamp, preventing the entry from showing up in queries.

However, in most cases, such applications are not used in isolation and have relational tables. Therefore, when, for example, an administrator soft-deletes an Organization entry, all related Projects, Tasks, and Comments should also be soft-deleted. 

On the other hand, the inverse operation is also common: when an administrator restores the Organization, it is necessary to decide whether to restore related entries or leave them deleted. If, for example, the user had previously deleted them. Note that declaring foreign keys with RESTRICT or CASCADE policies affects only the actual DELETE queries issued by the database and does not affect the case when the database is updated via the deleted_at column.

In this article, I will show how to implement a multi-level cascade soft delete/restores with Laravel model events.

The Problem: Orphaned Records and Broken Restorations

Consider a simple three-tier hierarchy:

  • An Account has many Projects.
  • A Project has many Tasks.

If you call $account->delete() with standard soft deletes:

  • Only the accounts table row gets a deleted_at timestamp. 
  • The child projects and tasks records are not deleted, they just have deleted_at = null
  • If someone queries tasks directly via Task::all() the orphaned tasks that belong to an inactive account will still show up in search results and background exports.

The usual bruteforce fix is to manually looping over relations in a controller:

// Messy controller approach
DB::transaction(function () use ($account) {
    foreach ($account->projects as $project) {
        $project->tasks()->delete();
        $project->delete();
    }
    $account->delete();
});

This controller code breaks when someone soft deletes an account from an artisan command, queue job, or test seeder. And, when restoring the account, you accidentally undelete a task, the user has intentionally deleted two months ago.

Method 1: Native Model booted() Hooks

The most direct way to handle cascading soft deletes is hooking into Eloquent's model lifecycle events inside the static booted() method.

Laravel fires the following events during soft delete lifecycles:

  • deleting and deleted: Triggered when calling delete().
  • restoring and restored: Triggered when calling restore().
  • forceDeleting and forceDeleted: Triggered when permanently purging records via forceDelete().

Step 1: Implementing Cascading on the Parent Model

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Account extends Model
{
    use SoftDeletes;

    protected $fillable = ['name'];

    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }

    protected static function booted(): void
    {
        // Cascade soft delete to child projects
        static::deleting(function (Account $account) {
            if ($account->isForceDeleting()) {
                // If the parent is being hard-purged, force delete children
                $account->projects()->withTrashed()->forceDelete();
            } else {
                // Soft delete active children
                $account->projects()->delete();
            }
        });

        // Cascade restoration to child projects
        static::restoring(function (Account $account) {
            $account->projects()->onlyTrashed()->restore();
        });
    }
}

Step 2: Implementing Cascading on the Intermediate Child Model

To cascade down multiple tiers, the intermediate model (Project) mirrors the same behavior for its children (Tasks):

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Project extends Model
{
    use SoftDeletes;

    protected $fillable = ['account_id', 'title'];

    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

    protected static function booted(): void
    {
        static::deleting(function (Project $project) {
            if ($project->isForceDeleting()) {
                $project->tasks()->withTrashed()->forceDelete();
            } else {
                $project->tasks()->delete();
            }
        });

        static::restoring(function (Project $project) {
            $project->tasks()->onlyTrashed()->restore();
        });
    }
}

When you execute $account->delete(), the deleting hook fires on Account, deleting its projects. In turn, each project's deleting hook fires, deleting its tasks. All three levels are soft deleted in order.

The Restoration Dilemma: Avoiding Accidental Restores

The hook approach described above has a edge case, which you have to consider if you are going to use it in a production environment: accidental restoration.

Imagine, you have deleted Task A by mistake on Monday. Then, on Friday, an admin accidentally soft-deletes your whole Account. Finally, on Saturday, he/she restores the Account.

Calling $project->tasks()->onlyTrashed()->restore() will restore all tasks that were soft-deleted, including Task A, which you have already deleted on Monday. To prevent this situation, you can store the deletion timestamp somewhere or set a special flag indicating that the record should not be restored. Or, you can check whether the child's deleted_at timestamp falls into a certain interval:

static::restoring(function (Project $project) {
    // Only restore tasks that were soft-deleted around the same time as the project
    $project->tasks()
        ->onlyTrashed()
        ->where('deleted_at', '>=', $project->deleted_at->subSeconds(5))
        ->restore();
});

Method 2: Building a Reusable CascadesSoftDeletes Trait

If you have many models that require this cascading behavior, you end up with lots of duplicated booted() hooks hard-coded throughout your application. It would be nice if we could extract this pattern into something that could be reused by pulling it into a trait that inspects a $cascadeDeletes property on the model.

Create app/Models/Concerns/CascadesSoftDeletes.php:

namespace App\Models\Concerns;

use Illuminate\Database\Eloquent\Model;

trait CascadesSoftDeletes
{
    /**
     * Boot the trait to listen for deletion and restoration events.
     */
    protected static function bootCascadesSoftDeletes(): void
    {
        static::deleting(function (Model $model) {
            foreach ($model->getCascadeDeleteRelations() as $relation) {
                if ($model->isForceDeleting()) {
                    $model->{$relation}()->withTrashed()->get()->each->forceDelete();
                } else {
                    $model->{$relation}()->get()->each->delete();
                }
            }
        });

        static::restoring(function (Model $model) {
            foreach ($model->getCascadeDeleteRelations() as $relation) {
                $model->{$relation}()
                    ->onlyTrashed()
                    ->where('deleted_at', '>=', $model->deleted_at->subSeconds(5))
                    ->get()
                    ->each
                    ->restore();
            }
        });
    }

    /**
     * Get the relationships that should cascade delete.
     *
     * @return array<int, string>
     */
    public function getCascadeDeleteRelations(): array
    {
        return property_exists($this, 'cascadeDeletes') ? $this->cascadeDeletes : [];
    }
}

Using the Trait on Your Models

Now, any model can configure cascading simply by defining an array of relationship names:

namespace App\Models;

use App\Models\Concerns\CascadesSoftDeletes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Company extends Model
{
    use SoftDeletes, CascadesSoftDeletes;

    protected $fillable = ['name'];

    // Relationships to automatically cascade delete and restore
    protected array $cascadeDeletes = ['departments', 'offices'];

    public function departments(): HasMany
    {
        return $this->hasMany(Department::class);
    }

    public function offices(): HasMany
    {
        return $this->hasMany(Office::class);
    }
}

Handling Database Transactions

Cascading through a table involves multiple sequential queries. If the server dies or timed out halfway through deleting the child records, then your database is now in an inconsistent state with half of the children deleted, but the parent still exists.

Always put multi level cascading operations inside a database transaction:

namespace App\Services;

use App\Models\Account;
use Illuminate\Support\Facades\DB;

class AccountDeletionService
{
    public function deleteAccount(int $accountId): void
    {
        DB::transaction(function () use ($accountId) {
            $account = Account::findOrFail($accountId);
            $account->delete(); // Cascades cleanly within the transaction boundary
        });
    }

    public function restoreAccount(int $accountId): void
    {
        DB::transaction(function () use ($accountId) {
            $account = Account::onlyTrashed()->findOrFail($accountId);
            $account->restore();
        });
    }
}

Model-Level Cascades vs. Direct Query Updates

Approach Query Method Fires Child Events? Memory & Scale
Direct Query $model->relation()->delete() ❌ No (direct SQL UPDATE) Fast, but stops child tiers from firing their own cascades.
Collection Iteration $model->relation->each->delete() ✅ Yes (fires deleting per child) Allows multi-tier cascading; loads child models into memory.

💡 Thumb Rule: If your children have their own children (3+ tiers deep), you should use collection iteration ($model->relation->each->delete()) so the child models boot and trigger their own downstream cascading hooks. If the relation is only 1 level deep and contains thousands of rows, use direct query updates ($model->relation()->delete()) to save memory.

Important Things to Remember

  • 📌Foreign key cascades do not work on soft deletes: Adding ->cascadeOnDelete() in your db migration will only affect actual SQL DELETE queries. It has no effect on update queries such as the ones generated by Eloquent when issuing a delete() method call on a model utilizing soft deletes.
  • ⚡Composite indexes for performance: When Laravel executes queries such as WHERE account_id = ? AND deleted_at IS NULL (which it does when loading soft deleted child relationships) make sure you have composite indexes on the foreign key columns as well as the deleted_at column in your child tables: $table->index(['account_id', 'deleted_at'])
  • ⚠️ Be careful when using massive child sets: If the account has 500,000 tasks, deleting them all in a web request by triggering cascades may result in a timeout. In cases of potentially massive child sets, it's a good idea to defer the deletion to a background job that deletes them in chunks, avoiding memory issues and long-running queries.

Conclusion

Using cascade soft delete and restore will make sure your application doesn't leave you with orphaned records in your database if a parent model is deleted. You can either put the code in the booted() method or create a trait that encapsulates the logic and allowing you to keep your controller code clean and focused.

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬


Previous Post Next Post

Contact Form