Skip to content
All posts

Soft Deletes Feel Safe Until You Join on a Table That Has Them

April 8, 2026·Read on Medium·

The global scope protects the model you query. It does not protect the table you join.

A client’s finance team flagged a reporting discrepancy. The monthly order summary was showing line items for products that no longer existed in the system. Products that had been deleted months ago. The numbers were wrong. The totals were inflated. The client wanted to know why deleted products were appearing in active reports.

I looked at the query. Both the Order model and the Product model had the SoftDeletes trait. The models were correctly set up. The migrations had the deleted_at columns. I had tested this in staging. It passed.

But the report was pulling soft-deleted products.

This took me longer to debug than I want to admit. And when I finally found it, the fix was one line. The lesson was not one line at all.

What Soft Deletes Actually Do

Before getting into the failure modes, you need to understand the mechanism.

When you add the SoftDeletes trait to a model, Laravel does two things. First, it overrides the delete() method to set deleted_at to the current timestamp instead of removing the row. Second, it registers a global scope called SoftDeletingScope that automatically appends WHERE deleted_at IS NULL to every query you run through that model.

// This is what SoftDeletingScope does under the hood
public function apply(Builder $builder, Model $model)
{
$builder->whereNull($model->getQualifiedDeletedAtColumn());
}

The key phrase is “through that model.” The global scope lives on the model. When Eloquent builds a query for Order::get(), it applies the scope to the orders table. That part works correctly.

The problem starts the moment you involve a second table.

Failure Mode 1: The Raw Join

This is the one that got me.

Order::join('products', 'orders.product_id', '=', 'products.id')
->select('orders.*', 'products.name', 'products.price')
->get();

This looks safe. You are querying through the Order model, so the SoftDeletingScope fires. The query will correctly exclude soft-deleted orders. But the scope applies only to the model being queried, which is Order. The products table is joined raw. Laravel has no idea that Product also uses soft deletes. Nothing filters out soft-deleted products.

The generated SQL looks like this:

SELECT orders.*, products.name, products.price
FROM orders
INNER JOIN products ON orders.product_id = products.id
WHERE orders.deleted_at IS NULL

Notice what is missing. There is no products.deleted_at IS NULL condition. Soft-deleted products come through clean.

The fix is manual. You have to add it yourself:

Order::join('products', 'orders.product_id', '=', 'products.id')
->select('orders.*', 'products.name', 'products.price')
->whereNull('products.deleted_at')
->get();

This is not a bug Laravel is going to fix for you. There have been open GitHub discussions proposing automatic soft delete filtering for joined models. The framework has not implemented this. The join is a raw SQL operation. The scope is an Eloquent concern. They do not overlap.

Failure Mode 2: The DB Facade

If you reach for DB::table() instead of Eloquent, you lose all soft delete protection on every table involved.

DB::table('orders')
->join('products', 'orders.product_id', '=', 'products.id')
->where('orders.status', 'completed')
->get();

This returns soft-deleted orders AND soft-deleted products. The SoftDeletingScope is an Eloquent construct. It attaches to Illuminate\Database\Eloquent\Builder. The DB facade uses Illuminate\Database\Query\Builder, a completely separate class. The scope never fires.

This is documented in the Laravel ecosystem but easy to forget when you are writing a quick reporting query and reach for DB:: because it feels simpler.

The fix requires you to manually guard both tables:

DB::table('orders')
->join('products', 'orders.product_id', '=', 'products.id')
->whereNull('orders.deleted_at')
->whereNull('products.deleted_at')
->where('orders.status', 'completed')
->get();

The problem with this approach is not the fix. It is that nothing enforces it. Every developer who writes a raw query against these tables has to remember. One forgotten whereNull and soft-deleted data leaks into your results silently.

Failure Mode 3: Existence Checks

This one is subtler. When you use ->has() or ->whereRelation() to filter based on a related model, soft-deleted related records are not automatically excluded.

// Returns orders that have at least one comment, including soft-deleted comments
Order::has('comments')->get();

If the Comment model uses SoftDeletes, you might expect this to exclude orders whose only comments are soft-deleted. It does not. The existence subquery does not automatically apply the soft delete scope of the related model.

The workaround is explicit:

Order::whereRelation('comments', 'comments.deleted_at', '=', null)->get();

This has been raised as a Laravel framework issue. The current behaviour is that global scopes on relationship models are not consistently applied in existence checks. The safe assumption is: if you care about soft-deleted records in a relationship query, specify it explicitly.

The Pattern Behind All Three Failures

The global scope protects the model you query. It does not protect tables you reference after that.

Any time you move data retrieval outside of a direct Eloquent model query, you are responsible for enforcing the soft delete filter yourself. This includes:

  • Raw join() calls on soft-deletable tables
  • Anything using DB::table()
  • DB::select() with raw SQL
  • Existence checks using ->has() against soft-deletable relationships
  • Any subquery written with the query builder directly

The mental model that “soft deletes just work” is only true when you query a single model in isolation. The moment the query spans multiple tables, the automation ends at the primary model’s table name.

The Hidden Performance Cost

Soft deletes add a WHERE deleted_at IS NULL condition to every query on that model. On a small table this is invisible. On a table with millions of rows that grows indefinitely because nothing is ever actually deleted, this becomes a full scan problem.

A standard index on a primary key or foreign key does not help with a WHERE deleted_at IS NULL filter unless you add a dedicated index. For most tables, a partial index works significantly better:

-- PostgreSQL
CREATE INDEX orders_active ON orders (id) WHERE deleted_at IS NULL;

-- MySQL does not support partial indexes natively.
-- Use a composite index instead.
CREATE INDEX orders_active ON orders (deleted_at, id);

The other cost nobody talks about is table bloat. Soft-deleted rows stay in the table forever unless you explicitly prune them. Over time your table contains a growing percentage of rows that every query has to skip. On a write-heavy system, this adds up.

Laravel provides the MassPrunable trait to schedule automatic permanent deletion of old soft-deleted records:

use Illuminate\Database\Eloquent\MassPrunable;

class Order extends Model
{
use SoftDeletes, MassPrunable;
public function prunable(): Builder
{
// Permanently delete soft-deleted orders older than 90 days
return static::onlyTrashed()
->where('deleted_at', '<=', now()->subDays(90));
}
}

Run php artisan model:prune on a schedule to keep the table clean.

One important caveat: MassPrunable deletes records using a single mass-deletion query. It never retrieves individual model instances before deleting them. This means your deleting and deleted model events will not fire. If you have observers or listeners attached to those events, use the regular Prunable trait instead, which retrieves each record before deletion and dispatches events normally.

When Soft Deletes Are Actually Worth It

Not every table needs them.

Soft deletes make sense when recovery is a real requirement. User accounts, documents, configurations, anything a human might accidentally delete and need back. They make sense for audit purposes when you need to preserve a record of what existed at a point in time.

They do not make sense for high-volume transactional tables where you need clean data and fast queries. They do not make sense if your data retention policy requires actual deletion. They do not make sense if your team writes a lot of raw joins and will inevitably forget the whereNull guard.

The most dangerous use of soft deletes is applying them everywhere by default because “it feels safer.” Safety that requires manual enforcement in every join query is not safety. It is a trap with good intentions.

The Checklist Before You Add SoftDeletes to a Model

Before adding the trait, ask these questions.

Is recovery a real requirement for this data, or is it a precaution that nobody has thought through? If it is just a precaution, use a hard delete and rely on database backups.

Does this table get joined in reporting queries? If yes, every developer who writes those queries needs to know about the whereNull requirement. Document it at the model level with a comment, not in a wiki nobody reads.

How fast does this table grow? If records accumulate fast, add a pruning strategy before you ship the soft delete, not six months later when the table has 10 million rows and 8 million of them are deleted.

Does your team use DB:: for any queries on this table? If yes, the soft delete protection is already partial. Make that explicit rather than leaving it as an assumption.

Soft deletes are a useful tool. But they protect less than most developers expect and cost more than most developers notice. The global scope is not magic. It is a WHERE clause on one table. Everything else is your responsibility.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
Soft Deletes Feel Safe Until You Join on a Table That Has Them — Hafiq Iqmal — Hafiq Iqmal