featured image

Implementing Soft Deletes with Cascade Logic and Content Moderation

Learn how to implement soft deletes in Laravel with cascade logic for content moderation, ensuring data integrity and compliance.

Published

Sat Nov 01 2025

Technologies Used

Laravel PostgreSQL SQL PHP
Advanced 41 minutes

Why Hard Deleting User Content Is Almost Always Wrong

When you need to delete user content in a production system, the naive approach — just run DELETE FROM — causes problems almost immediately. Foreign key constraints break. You lose data you may need for a legal dispute. You can’t recover from an accidental ban. You have no audit trail. And banned users can sometimes stay logged in for a while because the session wasn’t invalidated.

Soft deletes solve this by marking records as deleted without removing them. Combined with cascade logic, banning a user instantly hides all their content while leaving everything recoverable.

This tutorial covers the content moderation system in MyKpopLists: soft-deleting users, posts, lists, comments, and reviews; automatically hiding all content when a user is banned; restoring everything when they’re unbanned; maintaining referential integrity; and invalidating sessions so banned users are kicked out immediately.

Prerequisites and Setup

Knowledge you’ll need:

  • Laravel Eloquent: soft deletes trait, query scopes, observers
  • Database design: foreign keys, indexes, nullable columns
  • Middleware: request lifecycle, authentication checks
php artisan make:migration add_soft_delete_to_users_table
php artisan make:migration add_soft_delete_to_content_tables
php artisan make:middleware CheckBannedUser
php artisan migrate

Database Schema: What Goes Where and Why

The users table needs two separate concepts. deleted_at is Laravel’s standard soft delete timestamp for self-deletion. banned_at, banned_by, and ban_reason are for moderation — tracking who got banned, by whom, and why. Keeping them separate means ban logic and self-deletion logic don’t collide.

Schema::table('users', function (Blueprint $table) {
    $table->softDeletes()->after('updated_at');           // For self-deletion
    $table->timestamp('banned_at')->nullable()->after('deleted_at');
    $table->foreignId('banned_by')->nullable()->constrained('users')->after('banned_at');
    $table->text('ban_reason')->nullable()->after('banned_by');
});

Add softDeletes() to all content tables the same way. Don’t use onDelete('cascade') on foreign keys to content tables — that triggers hard deletes at the database level, bypassing soft delete logic entirely. Handle deletion in application code instead.

Unique constraints need attention with soft deletes. If a user with username “john” is soft-deleted, a new user trying to register as “john” will hit a duplicate key error because the old record still exists. A partial unique index fixes this in PostgreSQL:

CREATE UNIQUE INDEX users_username_unique 
ON users (username) 
WHERE deleted_at IS NULL;

The User Model: Ban, Unban, and Cascade

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Facades\DB;

class User extends Authenticatable
{
    use SoftDeletes;

    protected $fillable = [
        'name', 'username', 'email', 'password',
        'banned_at', 'banned_by', 'ban_reason',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'banned_at' => 'datetime',
        'deleted_at' => 'datetime',
    ];

    public function isBanned(): bool
    {
        return !is_null($this->banned_at);
    }

    public function isSuperAdmin(): bool
    {
        return $this->username === 'admin' && $this->email === 'admin@mykpoplists.com';
    }

    public function ban(User $bannedBy, string $reason = null): void
    {
        DB::transaction(function () use ($bannedBy, $reason) {
            $this->update([
                'banned_at' => now(),
                'banned_by' => $bannedBy->id,
                'ban_reason' => $reason,
            ]);

            $this->hideAllContent();
            $this->invalidateAllSessions();
        });
    }

    public function unban(): void
    {
        $this->update([
            'banned_at' => null,
            'banned_by' => null,
            'ban_reason' => null,
        ]);

        $this->restoreAllContent();
    }

    protected function hideAllContent(): void
    {
        $this->posts()->delete();
        $this->lists()->delete();
        $this->comments()->delete();
        $this->reviews()->delete();
    }

    protected function restoreAllContent(): void
    {
        $this->posts()->withTrashed()->restore();
        $this->lists()->withTrashed()->restore();
        $this->comments()->withTrashed()->restore();
        $this->reviews()->withTrashed()->restore();
    }

    protected function invalidateAllSessions(): void
    {
        DB::table('sessions')->where('user_id', $this->id)->delete();
        $this->tokens()->delete();
    }
}

The transaction wrapper around ban() is important. Without it, if the server crashes after hiding posts but before invalidating sessions, the user’s content is hidden but they can still browse the site. Transactions ensure all-or-nothing: either the entire ban completes or nothing changes.

Middleware to Kick Banned Users Out Immediately

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class CheckBannedUser
{
    public function handle(Request $request, Closure $next): Response
    {
        if (Auth::check() && Auth::user()->isBanned()) {
            $user = Auth::user();
            $banDate = $user->banned_at->format('F j, Y');
            $reason = $user->ban_reason ? " Reason: {$user->ban_reason}" : '';
            
            Auth::logout();
            $request->session()->invalidate();
            $request->session()->regenerateToken();
            
            return redirect()->route('login')->withErrors([
                'banned' => "Your account was banned on {$banDate}.{$reason} " .
                           "Please contact support if you believe this is an error."
            ]);
        }

        return $next($request);
    }
}

Register it in bootstrap/app.php to run on every web request:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\CheckBannedUser::class,
    ]);
})

Admin Controller: Three Levels of Deletion

class UserModerationController extends Controller
{
    public function ban(Request $request, User $user)
    {
        if ($user->isSuperAdmin()) {
            return redirect()->back()->withErrors(['ban' => 'Cannot ban super admin accounts.']);
        }

        if ($user->id === Auth::id()) {
            return redirect()->back()->withErrors(['ban' => 'You cannot ban yourself.']);
        }

        $request->validate(['reason' => 'required|string|max:500']);

        $user->ban(Auth::user(), $request->input('reason'));

        return redirect()->back()->with('success', "User {$user->username} has been banned.");
    }

    public function unban(User $user)
    {
        if (!$user->isBanned()) {
            return redirect()->back()->withErrors(['unban' => 'This user is not banned.']);
        }

        $user->unban();

        return redirect()->back()->with('success', "User {$user->username} has been unbanned.");
    }

    public function forceDelete(User $user)
    {
        if ($user->isSuperAdmin()) {
            return redirect()->back()->withErrors(['delete' => 'Cannot delete super admin accounts.']);
        }

        $username = $user->username;
        $user->posts()->withTrashed()->forceDelete();
        $user->lists()->withTrashed()->forceDelete();
        $user->comments()->withTrashed()->forceDelete();
        $user->reviews()->withTrashed()->forceDelete();
        $user->forceDelete();

        return redirect()->back()->with('success', "User {$username} has been permanently deleted.");
    }
}

The three deletion levels are: soft delete (user removes their own account, reversible), ban (admin action that hides content, reversible), and force delete (permanent, cascades through all content). The withTrashed() on content force-deletes matters — without it, soft-deleted posts from a previous ban would survive the permanent deletion.

How Laravel Does This Under the Hood

When you add the SoftDeletes trait, it registers a global scope that modifies every query:

// What Laravel adds automatically to every query
$builder->whereNull($model->getQualifiedDeletedAtColumn());
// Becomes: WHERE deleted_at IS NULL

So User::all() returns only active users. User::withTrashed()->get() removes that scope and returns everything. User::onlyTrashed()->get() adds a WHERE deleted_at IS NOT NULL condition instead.

A soft delete is just an UPDATE:

UPDATE users SET deleted_at = '2026-01-24 10:30:00' WHERE id = 42;

Banning a user with content fires seven queries: one UPDATE on the user, four soft-delete UPDATEs on content tables, and two DELETEs on sessions and tokens. All wrapped in a transaction.

Add an index on deleted_at in your migrations. Without it, every query with WHERE deleted_at IS NULL runs a full table scan. With the index, it’s a fast lookup. On a million-row table, that’s the difference between 500ms and 5ms.

Memory Management and the Problem With Restoring Large Content Sets

Restoring a user’s content looks straightforward but has a memory problem:

// This loads ALL posts into memory before restoring
$user->posts()->withTrashed()->restore();

For a user with 10,000 posts, this loads all 10,000 into memory at once. Use chunkById() instead:

$user->posts()->withTrashed()->chunkById(100, function ($posts) {
    foreach ($posts as $post) {
        $post->restore();
    }
});

chunkById() is safer than chunk() for iterating over records being modified. chunk() uses OFFSET which can skip or repeat records if rows are deleted during iteration. chunkById() uses WHERE id > ? instead, which doesn’t have this problem.

The Edge Cases That Will Bite You

Don’t use onDelete('cascade') with soft deletes. Database-level cascades trigger hard deletes, bypassing your soft delete logic. Handle all deletion in application code.

Content counts don’t exclude soft-deleted records by default. $user->loadCount('posts') may include deleted posts. Use withCount('posts') on the query builder — it respects the global soft delete scope.

Restoring content whose author is still banned. If you restore a specific post while the author remains banned, the post becomes visible but $post->user returns null (the user is still soft-deleted). Check $post->user->trashed() before allowing individual content restores.

Model events fire differently. Soft delete fires deleting and deleted. Force delete fires forceDeleting and forceDeleted. Restore fires restoring and restored. If you have observers that send emails on deletion, make sure they check $user->isForceDeleting() to distinguish between the two cases.

Periodically purge old soft-deleted records to manage storage:

User::onlyTrashed()
    ->where('deleted_at', '<', now()->subDays(90))
    ->forceDelete();

Soft deletes are your safety net. They enable undo functionality, maintain data integrity, and provide audit trails — none of which are recoverable from hard deletes after the fact.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!