Soft deletes — deleted_at, withTrashed(), onlyTrashed(), restore()
Concept
Soft deletes implement logical deletion: instead of issuing DELETE FROM posts WHERE id = ?, Laravel sets deleted_at = NOW() on the row. The row remains in the database and can be restored, audited, or permanently purged later. The feature is implemented via the Illuminate\Database\Eloquent\SoftDeletes trait and a corresponding SoftDeletingScope global scope that automatically appends WHERE deleted_at IS NULL to every query.
To enable soft deletes, use the SoftDeletes trait in the model and ensure the table has a nullable deleted_at TIMESTAMP column (add it with $table->softDeletes() in a migration). That single trait call registers the global scope in Model::bootSoftDeletes(), adds deleted_at to $dates, overrides the performDeleteOnModel() method to issue an UPDATE instead of a DELETE, and provides helper methods like restore(), forceDelete(), trashed(), withTrashed(), and onlyTrashed().
withTrashed() removes the SoftDeletingScope entirely — you see all rows. onlyTrashed() removes the scope and adds WHERE deleted_at IS NOT NULL. restore() sets deleted_at = NULL and fires restoring/restored events. forceDelete() issues a real DELETE statement, bypassing soft-delete mechanics entirely. Soft-deleted models also cascade to relationships only if you explicitly handle it — a soft-deleted User does not automatically soft-delete their Post records unless you do so in an observer or deleting hook.
One important gotcha: unique indexes still see soft-deleted rows. If users.email has a UNIQUE constraint and a soft-deleted user exists with foo@example.com, you cannot create a new user with that email without removing the soft-deleted record first. The common fix is a composite unique index on (email, deleted_at), or switching to application-level uniqueness validation with Rule::unique()->whereNull('deleted_at').
| Method | SQL | Includes soft-deleted? |
|---|---|---|
find(1) | WHERE id = 1 AND deleted_at IS NULL | no |
withTrashed()->find(1) | WHERE id = 1 | yes |
onlyTrashed()->get() | WHERE deleted_at IS NOT NULL | only deleted |
delete() | UPDATE SET deleted_at = NOW() | — |
forceDelete() | DELETE FROM ... | — |
restore() | UPDATE SET deleted_at = NULL | — |
Code Example
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
// deleted_at is automatically added to $dates by the trait
// No need to list it manually unless you want a custom column name:
// const DELETED_AT = 'removed_at';
}
// --- Migration ---
// Schema::table('posts', function (Blueprint $table) {
// $table->softDeletes(); // adds nullable TIMESTAMP deleted_at
// });
// --- Usage ---
// Soft delete (UPDATE, not DELETE)
$post = Post::find(1);
$post->delete();
// SQL: UPDATE posts SET deleted_at = '2025-06-12 10:00:00' WHERE id = 1
// Normal query — soft-deleted row is invisible
$posts = Post::all();
// SQL: SELECT * FROM posts WHERE deleted_at IS NULL
// Include soft-deleted rows
$allPosts = Post::withTrashed()->get();
// SQL: SELECT * FROM posts
// Only the deleted ones
$deleted = Post::onlyTrashed()->get();
// SQL: SELECT * FROM posts WHERE deleted_at IS NOT NULL
// Check if a specific model is soft-deleted
$post->trashed(); // bool
// Restore
$post->restore();
// SQL: UPDATE posts SET deleted_at = NULL WHERE id = 1
// Permanently delete (real DELETE)
$post->forceDelete();
// SQL: DELETE FROM posts WHERE id = 1
// Cascading soft deletes via observer
Post::observe(new class {
public function deleting(Post $post): void
{
$post->comments()->delete(); // soft-deletes all child comments too
}
});
// Unique email ignoring soft-deleted rows in validation
use Illuminate\Validation\Rule;
$rules = [
'email' => ['required', Rule::unique('users')->whereNull('deleted_at')],
];Interview Q&A
Q: How does Eloquent's SoftDeletes trait prevent soft-deleted records from appearing in normal queries?
The SoftDeletes trait's bootSoftDeletes() method calls static::addGlobalScope(new SoftDeletingScope()). SoftDeletingScope implements the Scope interface and inside apply() calls $builder->whereNull($model->getQualifiedDeletedAtColumn()). This appends AND deleted_at IS NULL to every query builder instance for that model class. Because it is a global scope, it applies automatically without any call-site intervention. withTrashed() removes this scope by calling withoutGlobalScope(SoftDeletingScope::class), restoring full visibility.
Q: What are the limitations of soft deletes with unique database constraints?
Database UNIQUE indexes are enforced at the storage engine level and see all rows regardless of application-layer soft-delete logic. If a soft-deleted row holds email = 'foo@example.com' and you try to insert a new user with the same email, the database will reject it with a duplicate-key error even though the existing user is "deleted" at the application level. Solutions include: (1) a composite unique index on (email, deleted_at) — works because deleted rows have a non-null deleted_at and the combination is unique; (2) purging soft-deleted records on a schedule; (3) using Rule::unique()->whereNull('deleted_at') for validation to prevent the insert at the application level before the DB rejects it.
Q: What is the difference between delete() and forceDelete() on a soft-deleting model, and when should you use each?
delete() on a SoftDeletes model calls performDeleteOnModel(), which issues UPDATE posts SET deleted_at = NOW() WHERE id = ? and fires the deleting/deleted events. The row is preserved for restore or audit purposes. forceDelete() bypasses the trait override and calls the original performDeleteOnModel() from Model, which issues a real DELETE FROM posts WHERE id = ?. Use forceDelete() for GDPR right-to-erasure scenarios, test teardown, or when a record is genuinely invalid. Never use forceDelete() as a substitute for delete() in normal application flows unless you have a specific reason to purge data permanently.