Blog
Soft Deleted Isn't Deleted
- laravel
- typescript
- databases
- architecture
Soft deletes feel like the safe choice. Nothing is really gone. Somebody deletes the wrong account, you restore it, everyone goes home.
That part works. The trouble is everything else that still thinks the row exists.
Because it does. A soft-deleted user is a row with a timestamp in deleted_at. It still has an email. It still has foreign keys pointing at it. It still sits in every index on the table. The only thing hiding it is a filter, and filters only apply where somebody remembered to put them.
The user who can't come back
Here's the one almost every app hits eventually.
A user deletes their account. Six months later they try to sign up again with the same email. They get "The email has already been taken."
From their side, that's nonsense. They deleted the account. From the database's side, it's completely correct. The row is right there.
There are usually two things saying no. The validation rule, and the unique index.
Fix the validation and you just move the error
Laravel's unique rule checks the table directly. It doesn't know about your model's soft delete scope, so trashed rows count:
'email' => ['required', 'email', 'unique:users,email'],You can tell it to ignore trashed rows:
use Illuminate\Validation\Rule;
'email' => [
'required',
'email',
Rule::unique('users', 'email')->withoutTrashed(),
],Now validation passes. Then the insert runs, hits the unique index on users.email, and throws an integrity constraint violation. You traded a friendly validation message for a 500.
The rule and the index have to agree on what "taken" means. Changing one without the other is how you get bugs that only show up for returning customers.
Make the index match the rule
What you usually want is "unique among rows that aren't deleted."
Postgres can say that directly with a partial index:
use Illuminate\Support\Facades\DB;
DB::statement(
'CREATE UNIQUE INDEX users_email_active_unique
ON users (email)
WHERE deleted_at IS NULL'
);Drop the old plain unique index on email in the same migration, or the partial one doesn't help.
MySQL doesn't have partial indexes. The usual workaround is a generated column that's only populated for live rows, with a unique index on that. MySQL allows multiple NULL values in a unique index, so trashed rows stop competing:
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['email']);
$table->string('active_email')
->nullable()
->storedAs('IF(deleted_at IS NULL, email, NULL)')
->unique();
});It's a little clever, so leave a comment in the migration. The next person to read that table will wonder why there are two email columns.
The other common approach is to mangle the value on delete, like appending the user ID to the email. It works, and it doesn't need any index tricks. It also means restoring the account means un-mangling it, and anything that searches by the original email misses the deleted row. Pick it on purpose, not by accident.
Restore has the same problem in reverse
Once deleted users free up their email, a new account can claim it. Now try to restore the old one.
With the partial index in place, that restore fails at the database. Which is good. Two live accounts with the same email is worse than an error. But it should fail with a message somebody can act on, not a stack trace in the admin panel:
public function restore(User $user): void
{
$taken = User::where('email', $user->email)->exists();
if ($taken) {
throw ValidationException::withMessages([
'email' => 'Another active account is using this email.',
]);
}
$user->restore();
}There's still a tiny window between the check and the restore. That's fine. The index is the real guard. The check is there so the common case gets a human answer.
The scope only covers Eloquent
The other way soft deletes bite you is quieter. Deleted rows show up where they shouldn't.
The SoftDeletes trait adds a global scope to the model. Eloquent queries on that model get deleted_at IS NULL added for you. Anything that isn't an Eloquent query on that model doesn't:
// Excludes trashed users
User::where('email', $email)->exists();
// Includes trashed users. No model, no scope.
DB::table('users')->where('email', $email)->exists();Joins are the sneaky version. The scope applies to the model you're querying, not the tables you join onto:
Order::query()
->join('users', 'users.id', '=', 'orders.user_id')
->where('users.plan', 'pro')
->select('orders.*')
->get(); // happily includes orders from deleted usersIf that's a revenue report, it's now wrong, and nobody gets an error about it.
Node doesn't have a scope at all
If a Node service reads the same database, there's no trait and no global scope. Every query has to remember on its own:
const { rows } = await pool.query(
'SELECT id, email FROM users WHERE email = $1 AND deleted_at IS NULL',
[email],
);That AND deleted_at IS NULL is going to get forgotten. Not maybe. Eventually.
When more than one codebase reads a soft-deleted table, I'd rather put the rule in the database where everyone gets it:
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;Point the reporting queries and the other services at active_users. Anyone who really needs the deleted rows can still query users directly, and that choice is visible in the code.
Decide what deleted means
Soft deletes aren't a feature you turn on. They're a decision about what "deleted" means in your app, and the database will make that decision for you if you don't.
So answer the questions up front:
- Can someone reuse the email, username, or slug of a deleted record?
- What happens if you restore a record whose unique value got claimed?
- Which queries need to see deleted rows, and which never should?
- Does anything else read this table without your ORM?
And sometimes the honest answer is that you don't need soft deletes at all. If what you really want is an audit trail, an archive table or an event log keeps history without leaving ghost rows in the live table.
Either way, write the rule down in the schema. A filter people have to remember is a filter somebody forgets.