On this page
- Purpose
- The Problem
- The Solution
- Prerequisites & Tooling
- Knowledge Base
- Environment Setup
- High-Level Architecture
- Soft Delete System Flow
- Tombstones
- The Implementation
- Database Schema for Soft Deletes
- Enable Soft Deletes in Models
- Implementing Ban Logic with Cascade
- Middleware to Block Banned Users
- Admin Controller for Banning Users
- Querying Soft-Deleted Records
- Under the Hood
- How Soft Deletes Work at the SQL Level
- Performance Implications
- Cascade Logic: The Domino Effect
- Memory Management: Batch Operations
- Edge Cases & Pitfalls
- Forgetting to Add Soft Deletes to Related Models
- Foreign Key Constraints with Soft Deletes
- Unique Constraints with Soft Deletes
- Restoring with Deleted Relationships
- Counting Relationships with Soft Deletes
- Soft Deleting with Events
- Conclusion
- What You’ve Learned
- The Production Pattern
- Real-World Impact
- Next Steps
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:
- Tombstone Marker:
deleted_attimestamp marks the record as “dead” - Epitaph:
ban_reasonexplains why it was deleted - Gravedigger:
banned_bytracks who performed the deletion - Resurrection: Setting
deleted_attonullbrings the record back - Hidden from View: Queries automatically exclude soft-deleted records
- 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 themban_reason: Text explanation for the ban (audit trail)
Why separate banned_at from deleted_at?
deleted_atis for soft deletes (user deletes their own account)banned_atis 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_atto the model’s casts - Modifies queries to exclude soft-deleted records by default
- Provides
restore(),forceDelete(), andtrashed()methods - Adds
withTrashed()andonlyTrashed()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:
- Soft Delete: User deletes their own account (reversible)
- Ban: Admin bans user, hides content (reversible)
- 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 ofOFFSET - More efficient for large datasets
Edge Cases & Pitfalls
Forgetting to Add Soft Deletes to Related Models
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:
- Database Design: Soft delete columns, indexes, and foreign key considerations
- Cascade Logic: Automatically hide/restore all user content when banned/unbanned
- Middleware Integration: Block banned users from accessing the application
- Session Management: Invalidate sessions and tokens for banned users
- Query Optimization: Efficient batch operations and transaction handling
- 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.