Adding the SoftDeletes trait to a Laravel 12 model takes seconds. You run a migration to add a deleted_at column, drop the trait onto your Eloquent model, and suddenly deleting a record sets a timestamp instead of purging the row. It feels safe. You can restore accidental deletions with a single method call.
Then your production database grows to several million rows, and customer support tickets start piling up. Users can't re-register after deleting their accounts, queries run at a crawl, and financial reports display inflated numbers because trashed records leaked into raw SQL queries. Soft deletes aren't just an ORM feature—they radically change how your underlying SQL engine handles indexing, uniqueness, and storage.
1. The Unique Constraint Trap
The most common bug introduced by laravel soft deletes involves unique database constraints. Suppose you have a users table where the email column must be unique. When an active user deletes their account, Eloquent sets deleted_at = '2025-03-30 14:00:00'. The record stays in the database table.
When that same user attempts to register again with the same email address, your Laravel validation rule might pass if you wrote it like this:
Rule::unique('users')->whereNull('deleted_at');The validation query executes SELECT COUNT(*) FROM users WHERE email = 'alex@example.com' AND deleted_at IS NULL, finds zero rows, and returns true. But when Eloquent executes the INSERT query, your database engine throws a hard SQL error: Integrity constraint violation: 1062 Duplicate entry 'alex@example.com' for key 'users_email_unique'.
Standard SQL unique indexes inspect the entire column across all rows regardless of whether your ORM considers a row "deleted". To the database, alex@example.com already exists.
Fixing Uniqueness with Partial Indexes
If you run PostgreSQL or SQLite, the cleanest fix is a partial unique index. A partial index only enforces uniqueness on rows that match a specific WHERE condition.
use Illuminate\Database\Migrations\Migration;\nuse Illuminate\Database\Schema\Blueprint;\nuse Illuminate\Support\Facades\DB;\nuse Illuminate\Support\Facades\Schema;\n\nreturn new class extends Migration\n{\n public function up(): void\n {\n Schema::create('users', function (Blueprint $table) {\n $table->id();\n $table->string('email');\n $table->softDeletes();\n $table->timestamps();\n });\n\n // PostgreSQL partial index\n DB::statement('CREATE UNIQUE INDEX users_email_active_unique ON users (email) WHERE deleted_at IS NULL');\n }\n\n public function down(): void\n {\n Schema::dropIfExists('users');\n }\n};MySQL doesn't support partial indexes directly. If you are on MySQL 8.0 or higher running on PHP 8.3, you have to use a generated virtual column to achieve the same isolation:
Schema::create('users', function (Blueprint $table) {\n $table->id();\n $table->string('email');\n $table->softDeletes();\n $table->timestamps();\n\n // MySQL 8.0+ generated column trick\n $table->string('active_email')\n ->virtualAs("CASE WHEN deleted_at IS NULL THEN email ELSE NULL END");\n \n $table->unique('active_email');\n});Since MySQL allows multiple NULL values in a unique index, any row with a non-null deleted_at generates a NULL inside active_email, bypassing the uniqueness lock while active users keep their constraints intact.
2. Index Bloat and Degrading Query Performance
When you enable soft deletes, every single standard Eloquent query receives a global scope under the hood: WHERE deleted_at IS NULL. That simple addition alters how database query planners choose and execute indexes.
Consider an order processing system with 10 million total orders, where 8 million rows are soft-deleted historical entries. If you have an index on (status, created_at), MySQL or PostgreSQL won't use that index as efficiently as you expect because it must continuously filter out soft-deleted records.
To fix this, developers typically re-create their indexes as composite indexes containing deleted_at, such as (deleted_at, status, created_at). While this speeds up individual queries, it introduces severe side effects:
Index Size Inflation: Adding a nullable timestamp column to every index increases index size on disk by 20% to 40%. Larger indexes mean fewer index pages fit inside memory (InnoDB Buffer Pool or Postgres shared_buffers).
Dead Storage Pages: Soft-deleting a row via
UPDATEsets a timestamp and marks the page as dirty. It does not free disk space. InnoDB keeps dead row versions around for MVCC readers, causing table bloat. A table query scanning 10,000 disk pages might spend 8,000 of those page fetches reading soft-deleted dead weight.Suboptimal Query Plans: Database query planners estimate row counts using histogram statistics. High ratios of null vs. non-null values in
deleted_atoften lead query planners to choose full table scans over index scans, taking query times from 15ms up to 450ms as data scales.
3. Silent Data Leaks in Joins and Raw Builder Queries
Laravel's SoftDeletingScope works brilliantly when you interact with single Eloquent models. The moment you step into raw query builder instances, direct SQL queries, or complex table joins, that safety net disappears without throwing an error.
Here is an example that breaks in production systems when building reporting dashboards:
// DANGEROUS: Query builder JOINs ignore Eloquent's SoftDeletingScope\n$monthlyRevenue = DB::table('orders')\n ->join('users', 'users.id', '=', 'orders.user_id')\n ->where('orders.created_at', '>=', now()->startOfMonth())\n ->sum('orders.total_cents');If a customer deletes their account, their soft-deleted user row remains in the database. The raw join above matches the order against the soft-deleted user and counts the revenue anyway. If you meant to exclude orders belonging to deleted users, your financial metrics are wrong, and you didn't get an exception or warning from Eloquent.
To prevent this leak in direct queries, you must manually check the target model's deletion state inside the join callback:
// CORRECT: Explicitly filter joined soft-deleted models\n$monthlyRevenue = DB::table('orders')\n ->join('users', function ($join) {\n $join->on('users.id', '=', 'orders.user_id')\n ->whereNull('users.deleted_at');\n })\n ->where('orders.created_at', '>=', now()->startOfMonth())\n ->sum('orders.total_cents');Relationship calls can also trigger unexpected behavior. If an Order belongs to a User, and that user soft deletes their account, calling $order->user returns null. If your frontend components or Next.js 16 server endpoints expect order.user.name to exist, your React 19 components will throw unhandled null reference exceptions during render.
Architecture Alternatives to Soft Deletes
Before adding SoftDeletes to every table in a new migration, evaluate whether you actually need soft deletes or if you simply need audit trails.
Dedicated Archive Tables: Instead of keeping dead records in your active
orderstable, move deleted rows to anarchived_orderstable using a database trigger or an explicit queue job. Your primary operational tables stay lean, indexes remain compact, and primary key/unique constraints work out of the box.Audit Logs / Event Sourcing: If you need soft deletes for compliance or tracking "who deleted what and when", use an immutable audit trail table (like spatie/laravel-activitylog). Record the deletion event, store the model's final payload as JSON, and execute a true hard delete on the source row.
Explicit Status Enums: If a record represents a domain state (such as a canceled subscription, suspended user, or deactivated product), use an explicit status column like
status = 'archived'instead of piggybacking ondeleted_at. This makes business logic explicit and avoids ORM magic hidden inside global scopes.












