softDeletableResources(), restoring with relationships, force deleting safely, soft delete scopes that cause silent bugs, the performance cost of deleted_at on large tables, and the three situations where soft deletes are the wrong choice entirely — the complete guide to deleting things in Laravel without losing them.
A support ticket comes in: a user’s account shows as active, but they can’t log in. Nobody deleted the row — the users table still shows it in a raw SQL query. But User::find($id) in Tinker returns null. Eloquent isn’t lying and the row isn’t gone; it’s soft-deleted, deleted_at is set, and every default query on that model silently filters it out — including the one the support engineer just ran to check if the account existed.
This is the thing about soft deletes that the “add SoftDeletes trait, done” tutorials skip: the trait doesn’t just add a restore path. It changes what “the record doesn’t exist” means, permanently, for every query against that model, everywhere in the codebase, unless someone explicitly opts back in with withTrashed(). Most of the bugs in this post come from that one sentence being forgotten somewhere.
This post covers softDeletableResources() for routing, restoring models along with their relationships, force-deleting safely when relationships and foreign keys are involved, the specific global-scope bugs that cause records to silently vanish from queries that should include them, what deleted_at actually costs on a large table, and the three situations where soft deletes are the wrong tool regardless of how standard they’ve become.
The Setup, and the Line Everyone Skips
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}
Schema::table('posts', function (Blueprint $table) {
$table->softDeletes(); // adds a nullable deleted_at timestamp
});
That’s the whole setup, and it’s genuinely all most tutorials cover. What they don’t spend enough time on: the SoftDeletes trait registers a global scope on the model. Every query — Post::all(), Post::where(...), $user->posts, a query inside a job, a query inside a scheduled command written eight months from now by someone who has never read this file — silently excludes soft-deleted rows unless it explicitly says otherwise.
Post::count();
// Only counts non-deleted posts — deleted_at IS NULL is added automatically
Post::withTrashed()->count();
// Counts everything, deleted or not
Post::onlyTrashed()->count();
// Counts only the deleted ones
This is correct behavior and it’s why soft deletes exist. The problem isn’t the scope — it’s that the scope is invisible at every call site that doesn’t use it. Post::where('author_id', $id)->count() looks complete. It silently excludes deleted posts, and nothing about reading that line tells you that.
softDeletableResources() — Routing Without the Repetition
Laravel 12.22 added softDeletableResources() specifically to stop ->withTrashed() from getting bolted onto every resource route by hand:
// Before — repeated on every soft-deletable resource
Route::resource('photos', PhotoController::class)->withTrashed();
Route::resource('posts', PostController::class)->withTrashed();
// After — one call, same behavior, no repetition
Route::softDeletableResources([
'photos' => PhotoController::class,
'posts' => PostController::class,
]);
This registers standard resource routes where the implicit model binding also considers trashed models instead of 404-ing on them. It’s route-registration sugar, not a change to the underlying query behavior — the controller still needs to explicitly decide what to do once it has a trashed model in hand (show a “this was deleted” banner, offer a restore action, whatever the UI needs). What it does remove is the class of bug where a soft-deletable resource gets added to a route file and someone forgets the ->withTrashed() call on that one route, so viewing a deleted record 404s instead of showing a proper “deleted” state.
If a resource is soft-deletable and needs its trashed records reachable through routing at all, softDeletableResources() is the version that doesn’t rely on remembering to chain a method onto every individual route definition.
Restoring With Relationships — Where “It Worked” Becomes a Lie
$post = Post::onlyTrashed()->find($id);
$post->restore();
This restores the post. It does not restore anything related to it. If deleting the post also soft-deleted its comments, tags, or attachments through a cascading delete, restore() on the parent does not cascade — Eloquent has no built-in concept of “restore everything that was deleted alongside this.”
// ❌ Restores the post. Comments stay soft-deleted. Post appears with zero comments.
public function restore(Post $post)
{
$post->restore();
}
// ✅ Restore the post and explicitly restore what should come back with it
public function restore(Post $post)
{
DB::transaction(function () use ($post) {
$post->restore();
$post->comments()->onlyTrashed()->restore();
$post->tags()->onlyTrashed()->restore();
});
}
Builder::restore() on a relationship query works the same way Builder::restore() works anywhere — it’s a mass update setting deleted_at to null for everything matching the query, so $post->comments()->onlyTrashed()->restore() is a single query, not an N+1 loop. The part that has to be deliberate is the transaction: restoring the parent without the children, or the children without the parent, because a request died in between, leaves the data in a state that’s worse than either fully deleted or fully present — a post with orphaned-looking comments, or comments referencing a post the UI still shows as deleted.
If cascading soft-delete-and-restore is a recurring need across several models, it’s worth writing as a trait once — a cascadeSoftDeletes array of relationship names on the model, with deleting and restoring model events driving the cascade — rather than hand-rolling the transaction in every controller that touches it. The failure mode without that discipline is consistent: the delete path gets the cascade because someone tested “does deleting work,” and the restore path doesn’t, because nobody manually tested “does un-deleting bring everything back” with the same rigor.
Force Deleting Safely
forceDelete() bypasses the soft-delete mechanism entirely and issues a real DELETE. This is where foreign key constraints stop being theoretical.
$post = Post::onlyTrashed()->find($id);
$post->forceDelete();
If comments.post_id has a foreign key constraint against posts.id with no ON DELETE CASCADE, and any comment — soft-deleted or not — still references this post, this throws a QueryException for a constraint violation. Soft-deleted comments still count; the row is still physically there.
// ❌ Assumes force-deleting the parent is safe because it "looks" orphaned
$post->forceDelete();
// SQLSTATE[23000]: Integrity constraint violation — comments still reference this post_id
// ✅ Force-delete children first, including the ones already soft-deleted
DB::transaction(function () use ($post) {
$post->comments()->withTrashed()->forceDelete();
$post->tags()->withTrashed()->forceDelete();
$post->forceDelete();
});
The withTrashed() here isn’t optional — a plain $post->comments()->forceDelete() only targets comments that aren’t already soft-deleted, silently leaving the soft-deleted ones behind to block the constraint on the next line. This is the single most common cause of “force delete works for some posts and throws for others” — it depends entirely on whether that particular post happens to have any already-trashed comments sitting under it.
For models where the schema does have ON DELETE CASCADE configured at the database level, force-deleting the parent is enough — the database handles the children regardless of their soft-delete state, because a foreign key cascade operates on physical rows, not on Eloquent’s deleted_at filtering. Know which case applies before assuming either behavior.
Before force-deleting anything reachable by a user action, the two questions worth answering explicitly:
- What foreign keys reference this row, and are they cascading at the database level or not?
- Is there a reason this needs to be unrecoverable at all, or would
delete()plus a scheduled prune later serve the same purpose with a safety window?
Soft Delete Scopes That Cause Silent Bugs
The global scope Eloquent adds is well-behaved on its own. The bugs show up at the boundaries — raw queries, aggregates, and relationships that don’t go through the model.
Raw queries bypass the scope entirely
// ❌ No Eloquent model in play — the global scope never applies
DB::table('posts')->where('author_id', $id)->count();
// Counts soft-deleted posts too — deleted_at is just another column here
DB::table() talks to the query builder directly, not through Eloquent, so there’s no model and no global scope to apply. This is correct SQL behavior and a common source of numbers that don’t match between two parts of the same codebase — one path counts through Post::where(...), another counts through DB::table('posts')->where(...), and they disagree by exactly the number of soft-deleted rows.
// ✅ Explicit if a raw query needs to respect soft deletes
DB::table('posts')->where('author_id', $id)->whereNull('deleted_at')->count();
Relationship counts silently exclude trashed rows
$user->posts()->count();
// Excludes the user's soft-deleted posts, same as any other query on Post
This is consistent with how the scope works everywhere else, but it’s the specific spot where it causes visible bugs — a “Posts: 12” count on a profile page that doesn’t match what a SELECT COUNT(*) against the raw table shows, because twelve is the live count and the raw table has three more that are soft-deleted. Neither number is wrong. They’re answering different questions, and the UI needs to be explicit about which one it’s showing.
Unique validation rules don’t know about soft deletes by default
// ❌ Rejects an email as taken, even if the only row with it is soft-deleted
'email' => 'required|email|unique:users,email'
Laravel’s unique rule runs a raw-ish existence check against the table — it does not go through Eloquent’s global scope, so a soft-deleted user with jane@example.com still blocks a new signup using that same email, even though User::where('email', 'jane@example.com')->first() would return null and make it look available everywhere else in the app.
// ✅ Explicitly ignore soft-deleted rows in the uniqueness check
use Illuminate\Validation\Rule;
'email' => [
'required',
'email',
Rule::unique('users', 'email')->whereNull('deleted_at'),
]
This is one of the more common “why can’t this user sign up, the account was deleted months ago” support tickets, and it’s caused by the exact opposite intuition most people have — the assumption is that soft-deleted means invisible everywhere, but the unique rule was never routed through the model’s global scope to begin with.
The Performance Cost of deleted_at on Large Tables
Soft deletes are not free, and the cost doesn’t show up until the table is large enough for it to matter — which is exactly when it’s expensive to fix.
Every default query carries an extra WHERE deleted_at IS NULL. On a well-indexed table this is cheap per-query. On a table without the right index, it’s a filter applied after a broader scan, and it gets more expensive as the proportion of soft-deleted rows grows relative to live ones. A posts table that accumulates soft-deleted rows for years without ever pruning them can end up mostly deleted data, with every “normal” query scanning past rows it will never return.
// A composite index that supports the common query shape:
// filter by author, only live rows, ordered by recency
Schema::table('posts', function (Blueprint $table) {
$table->index(['author_id', 'deleted_at', 'created_at']);
});
Put deleted_at in composite indexes alongside whatever columns the model is actually filtered and sorted by in practice — not as an afterthought column, but as a deliberate part of the index that matches the query shape the global scope produces on every call.
Soft-deleted rows still count against table size, still get scanned by backups, and still show up in SELECT * from anything that isn’t Eloquent. A table that’s accumulated years of soft-deleted rows with no pruning strategy is carrying dead weight through every full table operation — backups, replication, migrations that touch every row.
The fix that’s easy to skip: prune soft-deleted records on a schedule once they’re old enough that recovery is no longer a realistic need.
class Post extends Model
{
use SoftDeletes, Prunable;
public function prunable(): Builder
{
return static::onlyTrashed()->where('deleted_at', '<=', now()->subMonths(6));
}
}
// In the scheduler
Schedule::command('model:prune')->daily();
Prunable runs forceDelete() under the hood on whatever prunable() returns, on the schedule you configure. Six months of “we could still restore this if someone asks” is a reasonable default for most application data — the number that matters is whatever your actual recovery window commitment is, not an arbitrary Laravel default, because there isn’t one.
When Soft Deletes Are the Wrong Choice
Soft deletes have become close to a default in Laravel scaffolding, and defaults get applied past the point where they make sense. Three cases where they’re actively the wrong tool:
1. High-volume, low-value transactional data
Page view logs, notification-read receipts, rate-limit counters — data with no restoration value and enormous volume. Soft-deleting a table that generates millions of rows a day means the “excluded but still physically present” rows accumulate faster than any reasonable pruning schedule can clear them, and every default query pays the deleted_at filtering cost for data nobody will ever restore. Hard delete, or better, don’t delete at all — expire it with a TTL-based cleanup job that doesn’t route through Eloquent’s soft-delete machinery.
2. Data governed by a legal deletion requirement
GDPR-style “right to erasure” and similar regulatory deletion requirements mean the data has to actually be gone, not present-but-hidden-behind-a-timestamp. A soft-deleted row is still sitting in the table, still in every backup, still recoverable by anyone with direct database access — which is the opposite of what an erasure request is asking for. This needs a real, verifiable hard delete (and a process for scrubbing it from backups on whatever schedule the backup retention policy allows), not SoftDeletes with a longer prune window.
3. Data where “deleted” needs to mean something more specific than a timestamp
An order that’s been cancelled, refunded, or disputed isn’t well-modeled as “soft-deleted” — those are distinct states with different business logic, different queries, and different things that need to happen at each transition. Cramming all of that into deleted_at IS NOT NULL means the model can’t distinguish “cancelled by the customer” from “removed by an admin for fraud” from “soft-deleted because of an unrelated cleanup job,” and every place in the code that needs to tell those apart ends up adding a separate status column anyway. In that case, build the state machine explicitly — an enum status column with real transitions — rather than overloading soft deletes to carry meaning they were never designed to carry.
The One Rule
SoftDeletes changes what “doesn’t exist” means for a model, silently, at every call site — except the ones that don’t go through the model at all: raw DB::table() queries, uniqueness validation, foreign key constraints at the database level. Every bug in this post is some version of code written as if the global scope applies universally, in a spot where it doesn’t.
Before adding SoftDeletes to a model, the question worth asking isn’t “might we need to restore this someday” — it’s “do we have a real, bounded recovery window, a plan for what restoring actually cascades to, and a pruning schedule for when that window closes.” If the honest answer is “we’re adding it because it’s the Laravel default,” that’s usually the moment to check whether hard delete, a TTL, or an explicit status column was the right tool the whole time.
