Soft delete — marking rows as deleted rather than removing them
Beginner5 min read·eng-15-020
sqlinterview
Concept
Soft delete — marking a row as "deleted" by setting a timestamp (or flag) rather than physically removing it from the database. The row remains in the table but is excluded from normal queries.
Hard delete (physical delete): DELETE FROM users WHERE id = 1 — row is gone forever.
Soft delete: UPDATE users SET deleted_at = NOW() WHERE id = 1 — row stays but is hidden.
Why soft delete:
- Auditability: Business or legal requirement to keep records of everything, even after "deletion."
- Undo/restore: Restore a deleted record.
users/{id}/restore. - Referential integrity: Keeping FK references intact even after "deletion."
- Recycle bin: Users can "delete" and "recover" items (like email trash).
- Analytics: Historical data for reporting on deleted records.
The deleted_at column: Standard convention (used by Laravel's SoftDeletes trait). NULL = not deleted. Timestamp = deleted at that time.
Laravel's SoftDeletes trait:
- Adds
deleted_atcolumn automatically (via$table->softDeletes()in migration). delete()setsdeleted_at = NOW()instead of deleting.- All queries automatically get
WHERE deleted_at IS NULLvia a global scope. withTrashed()includes soft-deleted records.onlyTrashed()returns ONLY soft-deleted records.restore()setsdeleted_at = NULL.forceDelete()physically deletes the row.
Downsides:
- Storage bloat: Deleted rows accumulate forever.
- Query complexity: Must always exclude soft-deleted rows in raw queries.
- UNIQUE constraints: A soft-deleted email still blocks the UNIQUE index. Work around with
WHERE deleted_at IS NULLpartial unique index (PostgreSQL) or includedeleted_atin the unique key.
Code Example
php
<?php
// Migration — add deleted_at column
Schema::table('users', function (Blueprint $table) {
$table->softDeletes(); // adds: deleted_at TIMESTAMP NULL DEFAULT NULL
});
// Model
class User extends \Illuminate\Database\Eloquent\Model
{
use \Illuminate\Database\Eloquent\SoftDeletes; // that's all you need!
}
// Using soft deletes
$user = User::find(1);
$user->delete(); // UPDATE users SET deleted_at = NOW() WHERE id = 1
// Normal queries — soft-deleted users are INVISIBLE
$users = User::all(); // WHERE deleted_at IS NULL — Alice not included if deleted
// Include soft-deleted
$users = User::withTrashed()->get(); // all users including deleted
// Only deleted
$deleted = User::onlyTrashed()->get();
// Restore
$user = User::onlyTrashed()->find(1);
$user->restore(); // SET deleted_at = NULL
// Really delete
$user->forceDelete(); // DELETE FROM users WHERE id = 1
// UNIQUE constraint problem — soft delete + unique email
// Problem: user@example.com soft-deleted, new user tries to register with same email
// UNIQUE constraint blocks the insert even though the row is "deleted"
// Solution 1: Include deleted_at in unique constraint (MySQL workaround)
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('email');
$table->softDeletes();
// deleted_at is DATETIME NULL — NULLs don't count as duplicates in MySQL
// So email + deleted_at composite unique works!
$table->unique(['email', 'deleted_at']); // multiple NULLs allowed → soft-deleted + active can coexist
});
// Solution 2: Partial unique index (PostgreSQL)
\DB::statement("
CREATE UNIQUE INDEX users_email_unique
ON users (email)
WHERE deleted_at IS NULL
"); // only enforces uniqueness for non-deleted rows
// Solution 3: On restore/register — check for soft-deleted record first
$existing = User::onlyTrashed()->where('email', $email)->first();
if ($existing) {
$existing->restore();
$existing->update($newData);
} else {
User::create($newData);
}