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.

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

Following the unique-validation pattern covered elsewhere on this site, a naive unique validation rule can incorrectly reject a new record because the only "conflicting" row is actually soft-deleted — adding whereNull('deleted_at') to the unique rule (via Rule::unique()) is the fix for this specific interaction.

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.