Let's Connect

Rows of physical filing cabinets and archive boxes, representing the choice between keeping deleted records in place versus moving them to archival storage

The choice between Laravel soft deletes vs archiving comes down to one number: what percentage of your table is dead rows. SoftDeletes is the default reflex — add the trait, get a deleted_at column, and a deleted record stops showing up in queries while staying recoverable. That is genuinely useful for accidental-delete recovery. But soft-deleted rows never leave the table. On a busy orders or notifications table, you wake up one day to find 60% of the rows are trashed, your indexes are twice the size they need to be, and every single query is dragging a whereNull(deleted_at) filter through millions of dead rows. The fix is to stop treating soft delete as the end state: use it for short-term undo, then archive cold records out of the hot table on a schedule.

How do SoftDeletes actually work in Laravel?

SoftDeletes is a trait plus a nullable timestamp column. You add the trait to the model and the softDeletes() helper to the migration, which creates a nullable deleted_at column. From then on, calling delete() on the model sets deleted_at to the current time instead of issuing a real DELETE, and a global scope automatically filters those rows out of every normal query.

app/Models/Order.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Order extends Model
{
    use SoftDeletes;
}
database/migrations/xxxx_add_soft_deletes_to_orders.php
Schema::table('orders', function (Blueprint $table) {
    $table->softDeletes(); // adds a nullable `deleted_at` timestamp column
});

Once that is in place, the day-to-day API is small and you only really need four methods. The first three cover the recovery story; the last one is the one people forget exists.

  • $order->delete() sets deleted_at and hides the row — this is the soft delete.
  • Order::withTrashed()->get() includes trashed rows alongside live ones, for an admin trash view.
  • Order::onlyTrashed()->get() returns just the trashed rows, and $order->restore() un-deletes by nulling deleted_at.
  • $order->forceDelete() issues a real DELETE and removes the row for good — the only way data actually leaves the table.

For short-term undo this is exactly right. A user deletes an invoice, support restores it ten minutes later, nobody loses anything. The trait earns its place. The problem is what happens at month six.

Why do soft-deleted rows hurt performance over time?

Here is the gotcha that bites people in production: soft-deleted rows are still rows. They sit in the same table, occupy the same pages on disk, and live inside the same B-tree indexes as your live data. If your orders table has 5 million rows and 3 million of them are soft-deleted, your indexes are sized for 5 million entries even though only 2 million matter. Every index range scan reads through dead entries, the buffer pool caches pages full of garbage, and table scans do strictly more I/O than they should.

On top of the storage cost, the SoftDeletingScope appends AND `deleted_at` IS NULL to every query the model issues. That condition is rarely selective enough to use an index on its own, so the planner leans harder on your other indexes — and if you have a composite index that does not lead with the columns you actually filter on, you can end up scanning far more than you expect. I dig into how to read EXPLAIN and design indexes that survive this kind of scope in my database indexing guide; the short version is that a deleted_at predicate on a table that is mostly dead rows is exactly the situation where a missing or wrong index turns a 5ms query into a 500ms one.

A close-up of source code on a dark screen, representing the whereNull(deleted_at) scope that Laravel silently appends to every soft-deleted model query
Every query against a SoftDeletes model silently carries a whereNull(deleted_at) filter — cheap on a clean table, expensive on one that is mostly trash.

What does archiving look like, and when should I reach for it?

Archiving means moving records that are done — closed, expired, fulfilled, whatever 'cold' means for your domain — out of the hot table and into a separate archive table (or cold storage like S3 / Glacier for compliance dumps). The hot table stays lean and fast for the queries that run thousands of times a minute; the archive holds history you touch only when someone asks. You run the move on a schedule so it never piles up.

Laravel gives you a clean primitive for the deletion side of this with the Prunable trait. You add Prunable to the model and implement a prunable() method that returns a query for the records that are no longer needed. The model:prune command finds your prunable models automatically and deletes the matches. A key detail straight from the docs: if a model also uses SoftDeletes, prune calls forceDelete — so prune is what finally clears your soft-deleted backlog for good.

app/Models/Notification.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Notification extends Model
{
    use SoftDeletes, MassPrunable;

    /**
     * Get the prunable model query.
     */
    public function prunable(): Builder
    {
        // Drop anything soft-deleted more than 30 days ago.
        return static::where('deleted_at', '<=', now()->subDays(30));
    }
}

I reached for MassPrunable here on purpose: it deletes in bulk without hydrating each model, which is what you want for a high-volume table. The tradeoff is that the pruning() hook and the deleting / deleted model events do not fire, so if you need to clean up associated files or write an audit record per row, use the plain Prunable trait instead and do that work in pruning(). Then schedule it in routes/console.php.

routes/console.php
use Illuminate\Support\Facades\Schedule;

// Test first — reports how many rows WOULD be pruned, deletes nothing.
// php artisan model:prune --pretend

Schedule::command('model:prune')->daily();

If 'archive' means copy the row into an archive_orders table before it leaves the hot table, do that copy inside pruning() (with the plain Prunable trait), or run a dedicated job that INSERTs into the archive table and then prunes. When that move set gets large, batch it so a single nightly run does not lock the table or blow memory — I cover that batching pattern in my write-up on job batching for large background tasks. And whatever you delete or move, record who/what/when in your reusable audit log system — a pruned row with no audit trail is a support ticket you cannot answer.

Always dry-run with --pretend before you wire a prune into the scheduler. The first time you run a real prune against a table you misjudged, you find out in production that your prunable() query was off by an order of magnitude.

Soft delete is an undo button, not a storage strategy — if a row will never be restored, it has no business sitting in your hot table.Md Raihan Hasan

Where does GDPR fit — soft delete, archive, or hard delete?

This is where soft delete quietly becomes a liability. A soft-deleted row still contains the data. If a user invokes their right to erasure under GDPR and you only set deleted_at, you have not erased anything — the personal data is still in your database, still in your backups, still subject to the request you just failed to honour. Erasure means a real DELETE (forceDelete in Laravel terms), or genuine anonymisation of the personal fields, not a flag. Soft delete and archiving are about your operational convenience; a hard delete is a legal obligation, and you cannot satisfy the second with the first.

So separate the two concerns explicitly. Use soft delete for the operational undo window. Archive cold-but-keepable history out of the hot table to keep it fast. And run a real hard-delete path for personal data the moment retention rules or a user request demand it. A simple decision rule I apply on every project:

  • Might a human want this back within days or weeks (accidental delete, mis-click)? SoftDeletes, with a short prune window.
  • Is it closed/finished business you must keep for reporting or audit but rarely read? Archive it out of the hot table on a schedule.
  • Is it personal data under a retention limit or an erasure request? Hard delete (forceDelete) or anonymise — never leave it as a soft-deleted row.
  • Is the table tiny and rarely written? Honestly, leave the soft-deleted rows; the index bloat does not matter until volume does.

Tie the personal-data path to a retention policy in code, not in someone's head — a scheduled prune that forceDeletes records past their retention date is auditable and survives staff turnover. The official Laravel docs on the Prunable and MassPrunable traits are worth reading once end to end so you know exactly which events fire in each mode.

SoftDeletes is not the enemy and archiving is not the upgrade — they answer different questions. Reach for the trait when you need a cheap, reversible undo, and keep the recovery window short. The moment soft-deleted rows start outnumbering live ones, that is your signal to prune and archive so the hot table stays the size of your working set, not the size of your history. And carve out a separate, hard-deleting path for personal data, because the day a regulator or a user asks 'is this really gone?', a deleted_at flag is the wrong answer. Decide which of the three each table needs up front, wire the schedule, and you will never have to firefight a 90%-dead table under load.