0

Soft deletes — deleted_at, withTrashed(), onlyTrashed(), restore()

Intermediate5 min read·lv-12-018
sqlinterview

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').

MethodSQLIncludes soft-deleted?
find(1)WHERE id = 1 AND deleted_at IS NULLno
withTrashed()->find(1)WHERE id = 1yes
onlyTrashed()->get()WHERE deleted_at IS NOT NULLonly deleted
delete()UPDATE SET deleted_at = NOW()
forceDelete()DELETE FROM ...
restore()UPDATE SET deleted_at = NULL

Code Example

php
<?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.