artisanplaybook .dev
ARTICLES / ELOQUENT

Soft deletes: the gotchas that don't show up until production

The SoftDeletes trait is one of Eloquent's most reached-for features — add a deleted_at column, mix in the trait, and "deleted" rows quietly stop showing up in normal queries while staying in the database. That simplicity hides a few sharp edges: unique constraints don't know what "soft" means, relationships silently drop what you didn't expect them to, and cascades don't fire the way you'd assume.

ON THIS PAGE

What SoftDeletes actually does

Adding the trait and a nullable deleted_at column changes delete() from a real DELETE statement into an UPDATE that sets deleted_at to the current timestamp:

use Illuminate\Database\Eloquent\SoftDeletes;

class User extends Model
{
    use SoftDeletes;
}

Eloquent then adds a global scope that automatically excludes rows where deleted_at is not null from every normal query. restore() sets it back to null. forceDelete() bypasses all of this and performs a real, permanent DELETE.

That's the entire mechanism — a timestamp and a query scope. It's simple, and the simplicity is exactly what causes the following gotchas.

The unique constraint doesn't know a row is "deleted"

Say users.email has a unique index, and a user's account gets soft-deleted. Months later, the same person tries to sign up again with the same email address.

From the app's perspective, that email isn't in use — the soft-deleted row doesn't show up in any normal query. From the database's perspective, the row still physically exists, and the unique index still sees it. The insert fails, and depending on how you're handling that failure, the user sees either a raw database error or a confusing "this email is already registered" message for an account they can no longer find any trace of.

This isn't a bug in Eloquent — the database is doing exactly what a unique index is supposed to do. The mismatch is between "invisible to the app" and "gone from the table." A few ways to handle it:

Check for a trashed match before allowing signup, and handle it explicitly:

$existing = User::withTrashed()->where('email', $request->email)->first();

if ($existing?->trashed()) {
    // offer to restore the account, or route to support,
    // instead of a raw constraint violation
}

Free up the email at the moment of deletion, if reuse should just work without special-casing signup:

public function delete(): bool
{
    $this->update(['email' => $this->email . '-deleted-' . now()->timestamp]);

    return parent::delete();
}

Or enforce true DB-level reusability with a partial/generated unique constraint scoped to non-deleted rows (Postgres partial index, or a generated column on MySQL) — the more robust option if you have many places inserting data and can't rely on every code path remembering to check withTrashed() first.

Whichever you choose, the point is to choose deliberately — the default behavior (a plain unique index, no special handling) will eventually produce this exact confusing support ticket.

SoftDeletes' global scope applies everywhere, including through relationships. If a post's author was soft-deleted, this:

$post = Post::with('author')->first();
echo $post->author->name;

throws trying to call name on null — not because the relationship is broken, but because the global scope filtered the author out of the eager-loaded query, same as it would for a direct query. The post still has the correct user_id; the author row is just invisible by default.

If you need deleted authors to still resolve (common for anything historical — old posts, past orders, audit trails), pull them in explicitly:

$post = Post::with(['author' => fn ($query) => $query->withTrashed()])->first();

Or bake it into the relationship definition itself if trashed authors should always resolve for this relationship:

public function author(): BelongsTo
{
    return $this->belongsTo(User::class)->withTrashed();
}

Cascades don't cascade the way you'd expect

If you have a foreign key with ON DELETE CASCADE at the database level, it's tempting to assume soft-deleting a parent will cascade to its children the same way. It won't — ON DELETE CASCADE fires on an actual DELETE statement, and soft delete is an UPDATE. The database never sees a delete happen, so the cascade never triggers.

Concretely: soft-deleting a Post does nothing to its Comments unless you make that happen yourself. If your product's expectation is that deleting a post also hides its comments, that has to be explicit:

protected static function booted(): void
{
    static::deleting(function (Post $post) {
        $post->comments()->delete(); // also soft deletes, since Comment uses the trait too
    });
}

Note this only runs through Eloquent — a bulk Post::where(...)->delete() on a query builder instance doesn't fire model events per row, so cascades defined this way won't trigger there either. Same caveat applies to restore(): bringing a parent back doesn't automatically restore its children.

Checklist before adding SoftDeletes to a model

  • Does this table have any unique constraints? If so, decide explicitly how reuse of that value after a soft delete should behave — don't leave it to the default DB error.
  • For every belongsTo/hasMany relationship pointing at this model, would a trashed related row ever legitimately need to be displayed (historical data, audit trails)? If so, add withTrashed() where needed.
  • If this model has children that should logically disappear together with it, is that cascade handled explicitly in a deleting event, rather than assumed from a DB-level ON DELETE CASCADE?
  • Does restore() need to bring related children back too, or is that intentionally left out?

Related reading

ARCHIVE →