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

Purpose

The Problem

You need to delete user content, but:

  • Regulatory compliance: GDPR requires data retention for legal disputes
  • Accidental deletions: Users delete content by mistake and want it back
  • Content moderation: Banned users’ content should be hidden, not destroyed
  • Referential integrity: Deleting a user breaks foreign key relationships
  • Audit trails: You need to track who deleted what and when

The naive approach: Hard delete everything.

// ❌ NAIVE APPROACH - Permanent deletion
public function deleteUser(User $user)
{
    // Breaks foreign keys, loses data forever
    $user->posts()->delete();
    $user->comments()->delete();
    $user->delete();
    
    // What if they were banned by mistake?
    // What if we need their data for legal reasons?
    // What if other records reference this user?
}

Problems:

  • Data loss: Permanent deletion is irreversible
  • Broken relationships: Foreign key constraints violated
  • No audit trail: Can’t track who deleted what
  • Compliance issues: Can’t restore data for legal requests
  • Poor UX: No “undo” for accidental deletions

The Solution

Soft Deletes mark records as deleted without removing them from the database. Combined with cascade logic, you can hide entire content trees when users are banned.

What we’re building: The content moderation system from MyKpopLists that:

  • Soft deletes users, posts, lists, comments, and reviews
  • Automatically hides all user content when they’re banned
  • Restores all content when users are unbanned
  • Maintains referential integrity
  • Provides audit trails with timestamps and reasons
  • Invalidates sessions for banned users

This tutorial demonstrates database design, cascade operations, middleware integration, and building production-ready moderation systems—skills that define senior engineers.

Prerequisites & Tooling

Knowledge Base

  • Laravel Eloquent: Soft deletes trait, query scopes, observers
  • Database Design: Foreign keys, indexes, nullable columns
  • Middleware: Request lifecycle, authentication checks
  • PHP: Traits, protected methods, type hints

Environment Setup

# Generate migrations for soft deletes
php artisan make:migration add_soft_delete_to_users_table
php artisan make:migration add_soft_delete_to_content_tables

# Generate middleware for banned user checks
php artisan make:middleware CheckBannedUser

# Run migrations
php artisan migrate

High-Level Architecture

Soft Delete System Flow

graph TD
    A[Admin Bans User] --> B[User Model: ban method]
    B --> C[Set banned_at timestamp]
    B --> D[Set banned_by foreign key]
    B --> E[Set ban_reason text]
    
    C --> F[Cascade: Hide All Content]
    F --> G[Soft Delete Posts]
    F --> H[Soft Delete Lists]
    F --> I[Soft Delete Comments]
    F --> J[Soft Delete Reviews]
    
    G --> K[Set deleted_at on posts]
    H --> K
    I --> K
    J --> K
    
    K --> L[Invalidate Sessions]
    L --> M[Delete session records]
    L --> N[Revoke API tokens]
    
    N --> O[User Blocked]
    
    P[Admin Unbans User] --> Q[User Model: unban method]
    Q --> R[Clear banned_at]
    Q --> S[Clear banned_by]
    Q --> T[Clear ban_reason]
    
    T --> U[Cascade: Restore All Content]
    U --> V[Restore Posts]
    U --> W[Restore Lists]
    U --> X[Restore Comments]
    U --> Y[Restore Reviews]
    
    V --> Z[Clear deleted_at]
    W --> Z
    X --> Z
    Y --> Z
    
    Z --> AA[User Restored]

Tombstones

Think of soft deletes as tombstones in a graveyard:

  1. Tombstone Marker: deleted_at timestamp marks the record as “dead”
  2. Epitaph: ban_reason explains why it was deleted
  3. Gravedigger: banned_by tracks who performed the deletion
  4. Resurrection: Setting deleted_at to null brings the record back
  5. Hidden from View: Queries automatically exclude soft-deleted records
  6. Cascade Effect: When a user is buried, all their content is buried with them

Key Insight: Soft deletes are a state change, not a data removal. The record still exists in the database, just marked as deleted.

The Implementation

Database Schema for Soft Deletes

First, add soft delete columns to the users table:

<?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) {
            // Soft delete timestamp
            $table->softDeletes()->after('updated_at');
            
            // Ban-specific columns
            $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');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropSoftDeletes();
            $table->dropColumn(['banned_at', 'banned_by', 'ban_reason']);
        });
    }
};

Column Purposes:

  • deleted_at: Laravel’s soft delete timestamp (nullable)
  • banned_at: When the user was banned (separate from deleted_at)
  • banned_by: Foreign key to the admin who banned them
  • ban_reason: Text explanation for the ban (audit trail)

Why separate banned_at from deleted_at?

  • deleted_at is for soft deletes (user deletes their own account)
  • banned_at is for moderation (admin bans the user)
  • They serve different purposes and may have different restoration logic

Now add soft deletes to content tables:

<?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
    {
        // Add soft deletes to posts table
        Schema::table('posts', function (Blueprint $table) {
            $table->softDeletes()->after('updated_at');
        });

        // Add soft deletes to lists table
        Schema::table('lists', function (Blueprint $table) {
            $table->softDeletes()->after('updated_at');
        });

        // Add soft deletes to comments table
        Schema::table('comments', function (Blueprint $table) {
            $table->softDeletes()->after('updated_at');
        });

        // Add soft deletes to reviews table
        Schema::table('reviews', function (Blueprint $table) {
            $table->softDeletes()->after('updated_at');
        });
    }

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

        Schema::table('lists', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });

        Schema::table('comments', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });

        Schema::table('reviews', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });
    }
};

Enable Soft Deletes in Models

Add the SoftDeletes trait to your models:

<?php

namespace App\Models;

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

class User extends Authenticatable
{
    use SoftDeletes; // Enable soft deletes

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

    protected $casts = [
        'email_verified_at' => 'datetime',
        'banned_at' => 'datetime',
        'deleted_at' => 'datetime', // Automatically added by SoftDeletes
    ];

    /**
     * The user who banned this user.
     */
    public function bannedBy()
    {
        return $this->belongsTo(User::class, 'banned_by');
    }

    /**
     * Check if the user is banned.
     */
    public function isBanned(): bool
    {
        return !is_null($this->banned_at);
    }
}

Do the same for content models:

<?php

namespace App\Models;

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

class Post extends Model
{
    use SoftDeletes;

    protected $casts = [
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
        'deleted_at' => 'datetime',
    ];
}

// Repeat for Comment, Review, UserList models

What the SoftDeletes trait does:

  • Adds deleted_at to the model’s casts
  • Modifies queries to exclude soft-deleted records by default
  • Provides restore(), forceDelete(), and trashed() methods
  • Adds withTrashed() and onlyTrashed() query scopes

Implementing Ban Logic with Cascade

Add ban/unban methods to the User model:

<?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;

    /**
     * Ban the user and hide all their content.
     */
    public function ban(User $bannedBy, string $reason = null): void
    {
        // Update ban fields
        $this->update([
            'banned_at' => now(),
            'banned_by' => $bannedBy->id,
            'ban_reason' => $reason,
        ]);

        // Cascade: Hide all user content
        $this->hideAllContent();

        // Security: Invalidate all sessions
        $this->invalidateAllSessions();
    }

    /**
     * Unban the user and restore all their content.
     */
    public function unban(): void
    {
        // Clear ban fields
        $this->update([
            'banned_at' => null,
            'banned_by' => null,
            'ban_reason' => null,
        ]);

        // Cascade: Restore all user content
        $this->restoreAllContent();
    }

    /**
     * Hide all user content by soft deleting.
     */
    protected function hideAllContent(): void
    {
        // Soft delete posts
        $this->posts()->delete();
        
        // Soft delete lists
        $this->lists()->delete();
        
        // Soft delete comments
        $this->comments()->delete();
        
        // Soft delete reviews
        $this->reviews()->delete();
    }

    /**
     * Restore all user content.
     */
    protected function restoreAllContent(): void
    {
        // Restore posts (including soft-deleted ones)
        $this->posts()->withTrashed()->restore();
        
        // Restore lists
        $this->lists()->withTrashed()->restore();
        
        // Restore comments
        $this->comments()->withTrashed()->restore();
        
        // Restore reviews
        $this->reviews()->withTrashed()->restore();
    }

    /**
     * Invalidate all sessions for this user.
     */
    protected function invalidateAllSessions(): void
    {
        // Delete all sessions for this user
        DB::table('sessions')->where('user_id', $this->id)->delete();
        
        // Revoke all personal access tokens (Sanctum)
        $this->tokens()->delete();
    }

    /**
     * Check if this user is a super admin (cannot be banned).
     */
    public function isSuperAdmin(): bool
    {
        return $this->username === 'admin' && $this->email === 'admin@mykpoplists.com';
    }
}

Key Techniques:

  • Cascade operations: hideAllContent() soft deletes all related records
  • Restore operations: withTrashed()->restore() brings back soft-deleted records
  • Session invalidation: Prevents banned users from staying logged in
  • Token revocation: Invalidates API tokens for banned users
  • Super admin protection: Prevents banning critical admin accounts

Middleware to Block Banned Users

Create middleware to check if the authenticated user is banned:

<?php

namespace App\Http\Middleware;

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

class CheckBannedUser
{
    /**
     * Handle an incoming request.
     */
    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}" : '';
            
            // Log the user out
            Auth::logout();
            
            // Invalidate the session
            $request->session()->invalidate();
            $request->session()->regenerateToken();
            
            // Redirect to login with detailed error message
            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 the middleware in bootstrap/app.php:

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

What this does:

  • Checks if the authenticated user is banned on every request
  • Logs them out immediately if banned
  • Invalidates their session to prevent re-authentication
  • Shows a detailed error message with ban date and reason
  • Prevents banned users from accessing any part of the application

Admin Controller for Banning Users

Create an admin controller to handle ban/unban actions:

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

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

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

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

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

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

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

        // Unban the user
        $user->unban();

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

    /**
     * Permanently delete a user (force delete).
     */
    public function forceDelete(User $user)
    {
        // Prevent deleting super admins
        if ($user->isSuperAdmin()) {
            return redirect()->back()->withErrors([
                'delete' => 'Cannot delete super admin accounts.'
            ]);
        }

        // Force delete (permanent)
        $username = $user->username;
        
        // First, force delete all content
        $user->posts()->withTrashed()->forceDelete();
        $user->lists()->withTrashed()->forceDelete();
        $user->comments()->withTrashed()->forceDelete();
        $user->reviews()->withTrashed()->forceDelete();
        
        // Then force delete the user
        $user->forceDelete();

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

Three Levels of Deletion:

  1. Soft Delete: User deletes their own account (reversible)
  2. Ban: Admin bans user, hides content (reversible)
  3. Force Delete: Admin permanently deletes user and content (irreversible)

Querying Soft-Deleted Records

Laravel provides several methods to work with soft-deleted records:

// Default: Excludes soft-deleted records
$users = User::all(); // Only active users

// Include soft-deleted records
$users = User::withTrashed()->get(); // All users (active + deleted)

// Only soft-deleted records
$users = User::onlyTrashed()->get(); // Only deleted users

// Check if a model is soft-deleted
if ($user->trashed()) {
    echo "This user is deleted";
}

// Restore a soft-deleted record
$user->restore();

// Permanently delete (bypass soft delete)
$user->forceDelete();

Relationship Queries with Soft Deletes:

// Get posts for a user, excluding soft-deleted posts
$posts = $user->posts; // Only active posts

// Get posts including soft-deleted ones
$posts = $user->posts()->withTrashed()->get();

// Get only soft-deleted posts
$posts = $user->posts()->onlyTrashed()->get();

Counting with Soft Deletes:

// Count active posts
$activeCount = $user->posts()->count();

// Count all posts (including deleted)
$totalCount = $user->posts()->withTrashed()->count();

// Count only deleted posts
$deletedCount = $user->posts()->onlyTrashed()->count();

Under the Hood

How Soft Deletes Work at the SQL Level

Without Soft Deletes:

-- Hard delete: Record is gone forever
DELETE FROM users WHERE id = 1;

With Soft Deletes:

-- Soft delete: Just updates a timestamp
UPDATE users SET deleted_at = '2026-01-24 10:30:00' WHERE id = 1;

Query Modification:

When you use the SoftDeletes trait, Laravel automatically modifies all queries:

// Your code
User::where('email', 'user@example.com')->first();

// Generated SQL (automatically adds WHERE clause)
SELECT * FROM users 
WHERE email = 'user@example.com' 
AND deleted_at IS NULL;

How Laravel Does This:

The SoftDeletes trait adds a global scope to the model:

// Inside Illuminate\Database\Eloquent\SoftDeletes trait
protected static function bootSoftDeletes()
{
    static::addGlobalScope(new SoftDeletingScope);
}

The SoftDeletingScope modifies the query builder:

// Simplified version of SoftDeletingScope
public function apply(Builder $builder, Model $model)
{
    $builder->whereNull($model->getQualifiedDeletedAtColumn());
}

Bypassing the Global Scope:

// withTrashed() removes the global scope
User::withTrashed()->get();

// Equivalent to:
User::withoutGlobalScope(SoftDeletingScope::class)->get();

Performance Implications

Index Considerations:

-- Without index: Full table scan
SELECT * FROM users WHERE deleted_at IS NULL;
-- Execution time: 500ms (1 million rows)

-- With index: Fast lookup
CREATE INDEX idx_users_deleted_at ON users(deleted_at);
SELECT * FROM users WHERE deleted_at IS NULL;
-- Execution time: 5ms (same 1 million rows)

Why the index helps:

  • Database can quickly find all rows where deleted_at IS NULL
  • Avoids scanning every row in the table
  • Especially important for large tables with many soft-deleted records

Storage Impact:

Soft deletes increase storage requirements:

  • Active records: Normal storage
  • Deleted records: Still consume disk space
  • Solution: Periodically purge old soft-deleted records
// Purge soft-deleted records older than 90 days
User::onlyTrashed()
    ->where('deleted_at', '<', now()->subDays(90))
    ->forceDelete();

Cascade Logic: The Domino Effect

When you ban a user, here’s what happens step-by-step:

$user->ban($admin, 'Spam violation');

Step 1: Update User Record

UPDATE users 
SET banned_at = '2026-01-24 10:30:00',
    banned_by = 1,
    ban_reason = 'Spam violation'
WHERE id = 42;

Step 2: Soft Delete Posts

UPDATE posts 
SET deleted_at = '2026-01-24 10:30:00'
WHERE user_id = 42 AND deleted_at IS NULL;

Step 3: Soft Delete Lists

UPDATE lists 
SET deleted_at = '2026-01-24 10:30:00'
WHERE user_id = 42 AND deleted_at IS NULL;

Step 4: Soft Delete Comments

UPDATE comments 
SET deleted_at = '2026-01-24 10:30:00'
WHERE user_id = 42 AND deleted_at IS NULL;

Step 5: Soft Delete Reviews

UPDATE reviews 
SET deleted_at = '2026-01-24 10:30:00'
WHERE user_id = 42 AND deleted_at IS NULL;

Step 6: Invalidate Sessions

DELETE FROM sessions WHERE user_id = 42;
DELETE FROM personal_access_tokens WHERE tokenable_id = 42;

Total Queries: 7 queries (1 update + 4 soft deletes + 2 hard deletes)

Optimization: Use database transactions to ensure atomicity:

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();
    });
}

Why transactions matter:

  • Atomicity: All operations succeed or all fail (no partial bans)
  • Consistency: Database remains in a valid state
  • Isolation: Other queries don’t see intermediate states
  • Durability: Changes are permanent once committed

Memory Management: Batch Operations

Problem: Restoring 10,000 posts at once loads them all into memory.

// ❌ BAD: Loads all posts into memory
$user->posts()->withTrashed()->restore();

// If user has 10,000 posts, this loads all 10,000 into memory
// Memory usage: ~500MB

Solution: Use chunk() for large datasets:

// ✅ GOOD: Process in batches of 100
$user->posts()->withTrashed()->chunk(100, function ($posts) {
    foreach ($posts as $post) {
        $post->restore();
    }
});

// Memory usage: ~5MB (only 100 posts at a time)

Even Better: Use chunkById() for safer iteration:

// ✅ BEST: Chunk by ID (handles concurrent modifications)
$user->posts()->withTrashed()->chunkById(100, function ($posts) {
    foreach ($posts as $post) {
        $post->restore();
    }
});

Why chunkById() is safer:

  • Doesn’t skip records if rows are deleted during iteration
  • Uses WHERE id > ? instead of OFFSET
  • More efficient for large datasets

Edge Cases & Pitfalls

Problem: Banning a user doesn’t hide their content if models don’t use SoftDeletes.

// ❌ BAD: Post model without SoftDeletes
class Post extends Model
{
    // Missing: use SoftDeletes;
}

// When user is banned, posts are NOT hidden
$user->posts()->delete(); // Hard deletes instead of soft deletes!

Solution: Always add SoftDeletes trait to models that need it.

// ✅ GOOD: Post model with SoftDeletes
class Post extends Model
{
    use SoftDeletes;
}

// Now posts are soft deleted when user is banned
$user->posts()->delete(); // Sets deleted_at timestamp

Foreign Key Constraints with Soft Deletes

Problem: Foreign keys prevent deletion even with soft deletes.

// ❌ BAD: Foreign key with ON DELETE CASCADE
Schema::create('posts', function (Blueprint $table) {
    $table->foreignId('user_id')
        ->constrained()
        ->onDelete('cascade'); // This hard deletes!
});

// When user is soft deleted, posts are HARD deleted by database

Solution: Don’t use onDelete('cascade') with soft deletes.

// ✅ GOOD: Foreign key without cascade
Schema::create('posts', function (Blueprint $table) {
    $table->foreignId('user_id')
        ->constrained(); // No cascade
});

// Handle deletion in application code
$user->ban($admin, 'Violation');
// Application code soft deletes posts

Unique Constraints with Soft Deletes

Problem: Unique constraints don’t consider deleted_at.

// ❌ BAD: Unique constraint on username
Schema::create('users', function (Blueprint $table) {
    $table->string('username')->unique();
    $table->softDeletes();
});

// User "john" is soft deleted
// New user tries to register as "john"
// Error: Duplicate entry 'john' for key 'username'

Solution: Create a partial unique index that excludes soft-deleted records.

// ✅ GOOD: Unique constraint only for active users
Schema::create('users', function (Blueprint $table) {
    $table->string('username');
    $table->softDeletes();
    
    // PostgreSQL
    $table->unique('username', 'users_username_unique')
        ->where('deleted_at', null);
    
    // MySQL (use raw SQL)
    DB::statement('CREATE UNIQUE INDEX users_username_unique 
                   ON users (username) 
                   WHERE deleted_at IS NULL');
});

Alternative: Append timestamp to username on soft delete:

protected static function boot()
{
    parent::boot();
    
    static::deleting(function ($user) {
        if ($user->isForceDeleting()) {
            return;
        }
        
        // Append timestamp to username on soft delete
        $user->username = $user->username . '_deleted_' . time();
        $user->save();
    });
}

Restoring with Deleted Relationships

Problem: Restoring a post whose author is still banned.

// User is banned (soft deleted)
$user->ban($admin, 'Spam');

// Admin restores a specific post
$post = $user->posts()->withTrashed()->first();
$post->restore();

// Post is visible, but author is still banned
// Queries for $post->user return null (user is soft deleted)

Solution: Check relationship status before restoring:

public function restorePost(Post $post)
{
    // Check if author is banned
    if ($post->user->trashed()) {
        return redirect()->back()->withErrors([
            'restore' => 'Cannot restore post. Author is banned.'
        ]);
    }
    
    $post->restore();
    
    return redirect()->back()->with('success', 'Post restored.');
}

Counting Relationships with Soft Deletes

Problem: Relationship counts don’t exclude soft-deleted records by default.

// ❌ BAD: Counts include soft-deleted posts
$user->loadCount('posts');
echo $user->posts_count; // Includes deleted posts!

Solution: Use withCount() which respects soft deletes:

// ✅ GOOD: Only counts active posts
$user = User::withCount('posts')->find(1);
echo $user->posts_count; // Only active posts

// Include soft-deleted posts explicitly
$user = User::withCount(['posts' => function ($query) {
    $query->withTrashed();
}])->find(1);
echo $user->posts_count; // All posts (active + deleted)

Soft Deleting with Events

Problem: Model events fire differently for soft deletes vs. hard deletes.

// Soft delete fires: deleting, deleted
$user->delete();

// Force delete fires: forceDeleting, forceDeleted
$user->forceDelete();

// Restore fires: restoring, restored
$user->restore();

Use case: Send notification when user is banned:

// app/Observers/UserObserver.php
class UserObserver
{
    public function deleting(User $user)
    {
        if ($user->isForceDeleting()) {
            // Permanent deletion
            Log::info("User {$user->id} permanently deleted");
        } else {
            // Soft delete (ban)
            if ($user->isBanned()) {
                // Send notification to user
                Mail::to($user->email)->send(new AccountBannedMail($user));
            }
        }
    }
    
    public function restored(User $user)
    {
        // Send notification that account is restored
        Mail::to($user->email)->send(new AccountRestoredMail($user));
    }
}

Conclusion

What You’ve Learned

You’ve mastered production-grade soft deletes and content moderation:

  1. Database Design: Soft delete columns, indexes, and foreign key considerations
  2. Cascade Logic: Automatically hide/restore all user content when banned/unbanned
  3. Middleware Integration: Block banned users from accessing the application
  4. Session Management: Invalidate sessions and tokens for banned users
  5. Query Optimization: Efficient batch operations and transaction handling
  6. Edge Case Handling: Unique constraints, relationship restoration, and event hooks

The Production Pattern

Soft Delete System
├── Database Schema
│   ├── deleted_at (soft delete timestamp)
│   ├── banned_at (ban timestamp)
│   ├── banned_by (admin foreign key)
│   └── ban_reason (audit trail)
├── Model Methods
│   ├── ban() → Update ban fields + cascade hide
│   ├── unban() → Clear ban fields + cascade restore
│   ├── hideAllContent() → Soft delete all relationships
│   └── restoreAllContent() → Restore all relationships
├── Middleware
│   └── CheckBannedUser → Block banned users
└── Admin Controller
    ├── ban() → Ban user with reason
    ├── unban() → Restore user
    └── forceDelete() → Permanent deletion

Real-World Impact

In MyKpopLists, this system:

  • Protects data: 30-day retention before permanent deletion
  • Enables moderation: Admins can ban/unban users instantly
  • Maintains integrity: Cascade logic keeps content consistent
  • Provides audit trails: Track who banned whom and why
  • Improves UX: Users can restore accidentally deleted content

Next Steps

  • Advanced: Implement scheduled jobs to purge old soft-deleted records
  • Monitoring: Add logging and metrics for ban/unban actions
  • Testing: Write feature tests for ban/unban workflows
  • UI: Build admin dashboard for content moderation
  • Compliance: Add GDPR data export before permanent deletion

Remember: Soft deletes are your safety net. They enable undo functionality, maintain data integrity, and provide audit trails—essential for any production application with user-generated content.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!