Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 090 / 100

Soft deletes in Laravel: a practical PHP & Laravel guide

Recoverable deletes with one trait.

Expona Admin7 min read

Introduction

There are moments in every application when you need to delete a user record, but you're not quite ready to lose the data forever. Soft deletes in Laravel solve this elegantly by marking rows as deleted without actually removing them from the database. This practical PHP & Laravel guide will walk you through implementing recoverable deletes with one trait, handling the query behavior changes that come with it, and navigating the edge cases that catch most teams off guard.

We've all felt the pressure: a customer support request to restore deleted data, an accidental mass deletion, or regulatory requirements to retain historical records. Soft deletes give you that safety net while keeping your architecture clean.

Enable — Add SoftDeletes and a deleted_at column

Getting soft deletes running takes two steps. First, add the SoftDeletes trait to your model. Second, create a migration that adds a deleted_at timestamp column.

Here's the model setup:

php
<?php

namespace App\Models;

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

class User extends Model
{
    use SoftDeletes;

    protected $fillable = ['name', 'email', 'password'];
}

The trait registers a global scope that automatically filters out soft-deleted records from all queries. Without it, your queries would return deleted rows and you'd have to manually exclude them everywhere.

Next, create the migration:

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->softDeletes();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });
    }
};

The softDeletes() helper creates a nullable deleted_at timestamp. When you call delete() on a model, Laravel sets deleted_at to the current timestamp instead of removing the row. When you call restore(), it sets deleted_at back to null.

That's genuinely all you need. The trait handles everything behind the scenes.

Query behavior — Soft-deleted rows are excluded by default

The moment you add SoftDeletes, query behavior changes. Any query that doesn't explicitly ask for soft-deleted rows will exclude them automatically.

Here's what you need to know:

php
// Returns only active users (deleted_at is null)
$users = User::all();

// Returns only active users
$user = User::find($userId);

// Returns only active users
User::where('email', 'john@example.com')->first();

If you want to include soft-deleted records, use the withTrashed() method:

php
// Includes soft-deleted users
$allUsers = User::withTrashed()->get();

// Fetch a specific user even if deleted
$user = User::withTrashed()->find($userId);

For queries that only want soft-deleted records, use onlyTrashed():

php
// Get only soft-deleted users
$deletedUsers = User::onlyTrashed()->get();

// Count how many users we've soft-deleted
$count = User::onlyTrashed()->count();

This is powerful for audit trails and admin dashboards. We've used this pattern in several SaaS applications to show support teams a "deleted items" section where they can restore data.

Be aware: relationships also respect soft delete scopes. If a User has many Orders, and both models use SoftDeletes, then $user->orders will only return active orders. Use $user->orders()->withTrashed()->get() if you need deleted orders too.

Restore and force delete — restore() and forceDelete() when needed

Soft deletes give you two ways to clean up: reversible soft deletes and permanent hard deletes.

Restoring a soft-deleted model is straightforward:

php
$user = User::onlyTrashed()->find($userId);
$user->restore();

// The user is now active again (deleted_at is null)
$restored = User::find($userId); // This works now

You can also restore multiple records at once:

php
// Restore all users deleted in the last 7 days
User::onlyTrashed()
    ->where('deleted_at', '>=', now()->subDays(7))
    ->restore();

When you're genuinely ready to erase data from the database, use forceDelete():

php
// Permanently remove the user from the database
$user = User::onlyTrashed()->find($userId);
$user->forceDelete();

// Record is completely gone
$user = User::withTrashed()->find($userId); // Returns null

In production, we typically combine forceDelete() with a scheduled command. For example, a cleanup task that runs daily to permanently delete records soft-deleted over a year ago:

php
<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;

class PermanentlyDeleteOldData extends Command
{
    protected $signature = 'data:cleanup';

    public function handle(): void
    {
        $deleted = User::onlyTrashed()
            ->where('deleted_at', '<', now()->subYear())
            ->forceDelete();

        $this->info("Permanently deleted {$deleted} old records.");
    }
}

Schedule this in your app/Console/Kernel.php to run automatically.

Unique indexes — Remember deleted rows still occupy unique values

This is where soft deletes trip up most teams. A soft-deleted row still exists in your database, which means it still occupies space in your unique indexes.

Consider this scenario: you have a users table with a unique constraint on email. A user deletes their account:

php
$user = User::find(1);
$user->delete(); // Sets deleted_at, row still in table

// Later, someone tries to sign up with the same email
$newUser = User::create([
    'email' => 'john@example.com', // Same email
    'name' => 'John Doe',
]);
// Throws: Duplicate entry 'john@example.com' for key 'email'

The soft-deleted row blocks the new registration even though it's marked as deleted. You have three options:

Option 1: Use a composite unique index

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // Drop the old unique constraint if it exists
            $table->dropUnique('users_email_unique');
            
            // Create a composite unique that ignores soft-deleted rows
            $table->unique(['email', 'deleted_at']);
        });
    }
};

This works because NULL values in composite unique indexes don't conflict with each other. When deleted_at is NULL (active user), it's unique. When deleted_at has a value (deleted user), different NULL values can coexist.

Option 2: Regenerate the email on soft delete

php
<?php

namespace App\Models;

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

class User extends Model
{
    use SoftDeletes;

    protected static function booted(): void
    {
        static::deleting(function ($user) {
            // Append the user ID to make it unique
            $user->email = $user->email . '_' . $user->id . '_deleted';
            $user->save();
        });
    }
}

This modifies the email before soft-deleting, freeing up the original value. It's less elegant but straightforward.

Option 3: Don't use soft deletes for this model

If unique constraints are critical and restoration isn't needed, skip SoftDeletes and implement actual deletion. Archive data to a separate table if you need historical records.

We recommend Option 1 (composite unique index) for most cases. It's the cleanest and requires no extra logic.

Common mistakes

  • Forgetting to add the migration: The trait alone doesn't create the column. Without deleted_at, soft deletes silently fail and your data gets actually deleted.
  • Assuming all queries include soft-deleted rows: They don't. If you're building an admin panel or audit feature, you'll need withTrashed() explicitly.
  • Not handling unique constraints: Soft-deleted rows still block unique values. This surfaces unexpectedly in production when users try to re-register.
  • Cascading deletes with soft-deleted parents: If a User soft-deletes and has a cascade relationship to Orders, the orders will also soft-delete. Check your relationships; sometimes you want orders to remain.
  • Querying with raw SQL: Raw queries bypass Eloquent's soft delete scope. Use the query builder or Eloquent wherever possible.

Key takeaways

  • Add the SoftDeletes trait and create a deleted_at column to enable recoverable deletes.
  • Soft-deleted rows are automatically excluded from queries; use withTrashed() or onlyTrashed() to include or view only deleted records.
  • Call restore() to bring soft-deleted models back to life, or forceDelete() for permanent removal.
  • Handle unique constraints with composite indexes that include deleted_at, so deleted rows don't block new registrations.
  • Schedule permanent deletion of old soft-deleted records to keep your database lean and your historical retention compliant.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles