Skip to content

Soft Deletes Are Not Deletes, and That Is the Problem

The trait is one line and it changes what every unique constraint, every foreign key and every erasure request in the application means. Most codebases adopt it without deciding any of that.

4 min read

Adding SoftDeletes to a model takes one line, and every tutorial presents it as free undo. It is not free. It changes the meaning of deletion across the whole application, and the consequences land in places that have nothing to do with the model you added it to.

What actually happens

delete() stops deleting. It sets deleted_at and the row stays. A global scope hides it from queries. That is the entire mechanism, and every problem below follows from the row still being there.

Unique constraints stop working

A user registers with an address, deletes their account, and tries to register again with the same address. The row is still in the table, the unique index is still on the column, and the registration fails with a database error about a record the user cannot see and support cannot find.

The application says the account does not exist. The database says it does. Both are telling the truth about different things.

Two ways out, and they are choices rather than fixes:

// one live row, any number of deleted ones
$table->unique(['email', 'deleted_at']);

Or free the value at deletion, by rewriting it:

public function delete(): bool
{
    $this->email = "{$this->email}#deleted-{$this->id}";
    $this->save();
 
    return parent::delete();
}

The first keeps the original value and cannot enforce uniqueness across live and deleted rows together. The second frees the address and loses the original

  • which matters if you ever need to know who that account belonged to. Neither is wrong; picking neither is.

Relations come back looking wrong

A soft-deleted parent still satisfies its foreign keys, because the row exists. So a child row is not orphaned in the database, and a join that does not know about soft deletes will happily return the deleted parent.

Eloquent's scope hides it when you query the model. Raw queries, reports written in SQL, and aggregate queries built by hand do not - and that is where the numbers stop agreeing between two screens showing the same thing.

Worse, withTrashed() on one side of a relation and not the other produces a result that is correct in neither direction.

Erasure is not deletion

A person exercises their right to have their data erased. The application calls delete(). The row is still there, still readable by anyone with database access, still in every backup.

Nothing has been erased. The legal obligation is not met and the system reports that it was, which is worse than failing loudly.

An erasure path has to bypass the mechanism deliberately:

$user->forceDelete();

Or, where records must survive for tax or audit reasons, anonymise rather than remove - replace the personal fields, keep the row, and record that it happened. That is a decision with a lawyer in it, and the code has to implement whichever answer they give rather than the default.

The table only grows

Nothing prunes soft-deleted rows unless somebody writes the job. Years later the table is substantially rows nobody can see, indexes are larger than they need to be, and every query carries a deleted_at is null that the planner has to account for.

If the trait is on a model, there should be a retention policy and a job that enforces it. "We keep everything forever" is a valid policy; having no policy is how a table reaches forty million rows of which six million matter.

When it is genuinely right

Where undo is a feature users expect - an archive, a trash folder, a mistaken deletion that support should be able to reverse.

Where the record is referenced by history that must stay readable: an invoice naming a customer who has since been removed should still name them.

Where deletion is a workflow rather than an event, with an approval step between marking and removing.

That is a narrower set than "every model", which is where the trait usually ends up. The default should be a real delete, with the trait added where somebody can say what undo means for that table - and where somebody has answered the unique-constraint question, the reporting question, and the retention question before the first row is deleted rather than after the first support ticket.

Unique constraints and reporting are schema questions. Retrofitting either onto a table that has been soft-deleting for two years is data work, and it takes longer than people expect. The erasure question has a legal deadline attached, so it belongs with the other decisions you cannot take back.

In two places this stops being a design preference. Quebec's Law 25 puts a deadline on erasure. Spain's invoice chain forbids deleting the record that the deadline reaches. Reconciling those is a schema conversation, and it is much easier before either system is built.

Related questions

Should we use soft deletes by default?
No - default to hard deletes and add the trait where there is a reason. The reason is usually that somebody needs to undo, or that the record is referenced by history that must stay readable. Applied everywhere by habit, it turns every table into an archive nobody prunes and every constraint into a half-truth.
How do we handle unique columns?
Either include the deleted timestamp in the unique index, which allows one live row plus any number of deleted ones, or stop using the natural value as the unique key and free it on deletion by rewriting it. Both are deliberate choices; what does not work is a plain unique index on a soft-deleting table.
Does a soft delete satisfy an erasure request?
No, and this is the one that carries a legal consequence rather than an engineering one. The data is still there and still readable, so nothing has been erased. An erasure request needs real removal or real anonymisation, with the soft-delete mechanism explicitly bypassed.
What about the rows pointing at the deleted one?
They keep pointing at it, because the row is still there and the foreign key is still satisfied. That is often what you wanted - an old invoice still names its customer - and it is a decision to make per relation rather than a property of the trait.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page