Awwwards
How To: Soft Deleting and Restoring in Laravel's Hero Image

How To: Soft Deleting and Restoring in Laravel

In the app that we're building, we have some hasMany relationships that also implement the SoftDeletes trait. This causes a little confusion when deleting parent objects and then having to go through and delete all the children objects. I'll unpack how I tackled this issue and point to some packages that handle this exact scenario.

Deleting

We have this concept of Groups and Questions where Groups can have many Questions, but a Question can only belong to one Group. And we're using Laravel's SoftDeletes trait to make "archiving" data a little easier. When a User deletes a Question, Laravel sets the deleted_at column to the current timestamp and subsequent queries will make sure to only pull Questions where deleted_at is null. When a User restores the Question, Laravel blows out the deleted_at column and everything is back to normal. When using SoftDeletes, there is also the concept of forceDeleting which just means that the record actually gets scrubbed from the database and isn't retrievable anymore.

How then, would you go about making sure that when you soft-delete a Group all the Questions that belong to it are soft-deleted as well? I'll show you my first approach.

// Group.php

public function delete()
{
    $this-questions()->delete();
    return parent::delete();
}

This is actually totally fine and will get the job done. But it's a little ugly, a little gross, and a it feels like there's got to be a better way.

After some searching (and stumbling upon Michael Dyrynda's soft delete package way too late), I came across the idea of hooking into an Eloquent model event.

Instead of overriding the delete() function, I can instead put the cascade delete logic on the model itself within the boot() function.

Here's what that looks like:

protected static function boot()
{
    parent::boot();
    
    static::deleting(function ($group) {
        if ($group->forceDeleting) {
            $group->questions()->forceDelete();
        } else {
            $group->questions()->delete();
        }
    });
}

Since this also handles the forceDelete situation where you want to actually scrub the item from the database, I no longer need to override the forceDelete() function!

Note: I recommend allowing Users to forceDelete items only if the item is currently deleted.

Restoring

But what about restoring items that have been trashed? It's easy when you're restoring a single item because you only have to use the restore() function and you're done. But what about restoring a Group and wanting to restore the Questions as well? Well you might think to just do $group->questions()->restore(); which would technically work except when a Question was legitimately deleted prior to deleting the Group. You don't actually want to restore all the Questions for a Group, you just want to restore the Questions that were deleted at the same time as the Group.

Well, thanks to Laravel, that's pretty easy too.

protected static function boot()
{
    parent::boot();
    
    // static::deleting...
    
    static::restoring(function ($group) {
        $groups->questions()
            ->onlyTrashed()
            ->whereBetween('deleted_at', $group->getDeletedAtRange())
            ->restore();
    });
}

The key here is the whereBetween clause when determining which Questions to restore. I plopped the getDeletedAtRange() function into a Trait that I pull in wherever I need to "cascade restore".

public function getDeletedAtRange($secondsBefore = 2, $secondsAfter = 1)
{
    return [
        $this->deleted_at->subSeconds($secondsBefore),
        $this->deleted_at->addSeconds($secondsAfter),
    ];
}

This just builds a range based on the current model's (in this case the Group) deleted_at column. I define the secondsBefore and secondsAfter because I don't want to be caught in a situation where an unusually long operation causes the timestamps to be out of sync.

So, without overriding the default delete(), restore(), and forceDelete() functions on each Model and by hooking into the Eloquent events, I'm able to delete, restore, and forceDelete to my heart's content.

Packages

Keep in mind there are packages out there that incorporate more logic, safeguards, and other bells and whistles that I'm not taking advantage of yet. I’ve listed a couple here, but let me know @psamg and I’ll add them here!


Patrick Guevara's Profile Picture

Patrick Guevara


Chief Software Engineer

Patrick cofounded Metric Loop on the dream of building really great software with a clean, transparent approach. He lives in Austin, Texas with his wife Jess and their dog named Moose.