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