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.