Reader Stacks

Working With Soft Deletes in Laravel: Querying, Restoring, and Permanently Deleting

Soft-deleted records vanish from normal queries by default — a specific set of methods (withTrashed, onlyTrashed, restore, forceDelete) is what's needed to actually see and manage them.

Working With Soft Deletes in Laravel: Querying, Restoring, and Permanently Deleting

The SoftDeletes trait makes a "deleted" record set a deleted_at timestamp instead of being physically removed — but this also means normal queries silently exclude soft-deleted rows by default, which is exactly why a specific set of methods exists for querying, restoring, and permanently removing them.

Enabling soft deletes on a model

use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}
Schema::table('posts', function (Blueprint $table) {
    $table->softDeletes(); // adds a nullable deleted_at column
});

Including soft-deleted records in a query

$allPosts = Post::withTrashed()->get();

withTrashed() is what includes soft-deleted rows alongside normal, active ones — without it, Eloquent's default global scope on a SoftDeletes model silently excludes them from every query automatically.

Getting only the soft-deleted records

$trashedPosts = Post::onlyTrashed()->get();

Checking if a specific model instance is soft-deleted

if ($post->trashed()) {
    // this record has been soft-deleted
}

Restoring a soft-deleted record

$post = Post::onlyTrashed()->find(5);
$post->restore();

// Restoring multiple records at once
Post::onlyTrashed()->where('created_at', '<', now()->subYear())->restore();

Permanently deleting a record

$post = Post::onlyTrashed()->find(5);
$post->forceDelete();

// Permanently deleting all soft-deleted records older than a year
Post::onlyTrashed()->where('deleted_at', '<', now()->subYear())->forceDelete();

forceDelete() is the one operation here that's genuinely irreversible — unlike a normal delete() call on a SoftDeletes model, which just sets deleted_at and can be restored, this permanently removes the row from the database.

Filtering relationships to exclude or include trashed related records

// Eager load only non-trashed comments (default behavior)
$post = Post::with('comments')->find(1);

// Eager load comments including soft-deleted ones
$post = Post::with('comments' => function ($query) {
    $query->withTrashed();
})->find(1);

A common gotcha: unique validation and soft deletes

A naive unique validation rule still considers soft-deleted rows. On current Laravel versions, Rule::unique('posts', 'slug')->withoutTrashed() expresses the intent directly; an explicit whereNull('deleted_at') condition is the older equivalent when you need custom scoping.

Choosing between soft delete and permanent delete for a given feature

Soft deletes suit data where recovery, audit trails, or "trash/restore" UX genuinely matter (user accounts, orders, published content) — for data that's naturally disposable and never needs recovery (temporary cache entries, expired tokens), adding the overhead of a deleted_at column and remembering to account for it in every query is unnecessary complexity.

Soft deleting does not cascade through Eloquent relationships automatically

If a post is soft-deleted, related comments are not automatically soft-deleted just because both models use SoftDeletes. Decide whether children should remain active, be soft-deleted with the parent, or become inaccessible only because the parent is hidden. Implement that policy explicitly in model events, an application service, or a database design that matches the feature.

Restoration needs the inverse policy too

If deleting a parent deliberately soft-deletes children, restoration has to decide which children to restore. Restoring every trashed child can resurrect a comment that had been deleted independently before the parent was removed. A robust design records enough state to distinguish "deleted because parent was deleted" from "already deleted for another reason" instead of assuming every deleted_at timestamp means the same thing.

Route and relationship queries keep applying the global scope

A soft-deleted model can therefore seem to "disappear" from more places than an index query: route-model binding, nested relationships, eager loading, counts, and existence checks all start from scoped Eloquent queries unless you opt into trashed rows. When building an admin trash view, trace each query boundary rather than adding withTrashed() only to the first model and assuming related queries inherit it.

Soft delete is not a complete audit trail

deleted_at tells you that a row was deleted and roughly when. It does not tell you who deleted it, why, what fields looked like before later edits, or which related actions happened around the deletion. If those facts matter for compliance or operations, add a real audit/event history rather than expecting soft deletes to carry information they were never designed to store.