Summary: Master Laravel's soft delete functionality with advanced techniques for data recovery, comprehensive reporting, and maintaining data integrity. Learn how to implement audit trails, versioning systems, and complex restoration workflows.
Table Of Contents
- Introduction
- Understanding Soft Deletes Fundamentals
- Advanced Soft Delete Patterns
- Advanced Recovery Systems
- Comprehensive Audit and Versioning
- Advanced Reporting and Analytics
- Background Jobs for Cleanup
- Testing Soft Delete Functionality
- Related Posts
- Conclusion
Introduction
Soft deletes are one of Laravel's most powerful features for data integrity and user experience. Rather than permanently removing records from your database, soft deletes mark them as deleted while keeping the data intact. This approach provides safety nets for accidental deletions, maintains referential integrity, and enables comprehensive audit trails.
Beyond basic soft delete functionality, Laravel offers sophisticated tools for managing deleted records, creating restoration workflows, and building comprehensive reporting systems. Understanding these advanced patterns enables you to build resilient applications that handle data lifecycle management with confidence and flexibility.
Understanding Soft Deletes Fundamentals
Basic Soft Delete Implementation
Let's start with implementing soft deletes in your models:
// app/Models/Post.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
use SoftDeletes;
protected $fillable = [
'title',
'content',
'user_id',
'published_at'
];
protected $casts = [
'published_at' => 'datetime',
'deleted_at' => 'datetime'
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isPublished(): bool
{
return $this->published_at !== null && $this->published_at->isPast();
}
public function isDraft(): bool
{
return $this->published_at === null;
}
}
Migration Setup for Soft Deletes
// database/migrations/create_posts_table.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::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->softDeletes(); // Adds deleted_at column
$table->index(['deleted_at']);
$table->index(['user_id', 'deleted_at']);
$table->index(['published_at', 'deleted_at']);
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
Advanced Soft Delete Patterns
Cascading Soft Deletes
Implement cascading soft deletes to maintain referential integrity:
// app/Models/User.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\HasMany;
class User extends Model
{
use SoftDeletes;
protected $fillable = [
'name',
'email',
'email_verified_at'
];
protected $casts = [
'email_verified_at' => 'datetime',
'deleted_at' => 'datetime'
];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
protected static function booted(): void
{
static::deleting(function (User $user) {
if ($user->isForceDeleting()) {
// Force delete related records
$user->posts()->forceDelete();
$user->comments()->forceDelete();
} else {
// Soft delete related records
$user->posts()->delete();
$user->comments()->delete();
}
});
static::restoring(function (User $user) {
// Restore related records when user is restored
$user->posts()->withTrashed()->restore();
$user->comments()->withTrashed()->restore();
});
}
}
Conditional Soft Deletes
Implement conditional logic for when to soft delete vs hard delete:
// app/Models/Document.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Document extends Model
{
use SoftDeletes;
protected $fillable = [
'title',
'content',
'user_id',
'is_important',
'retention_period_days'
];
protected $casts = [
'is_important' => 'boolean',
'retention_period_days' => 'integer',
'deleted_at' => 'datetime'
];
public function delete()
{
// Important documents should always be soft deleted
if ($this->is_important) {
return parent::delete();
}
// Check if retention period has passed
$retentionPeriod = $this->retention_period_days ?? 30;
$cutoffDate = now()->subDays($retentionPeriod);
if ($this->created_at->lt($cutoffDate)) {
// Old, non-important documents can be force deleted
return $this->forceDelete();
}
// Default to soft delete
return parent::delete();
}
public function canBeRestored(): bool
{
if (!$this->trashed()) {
return false;
}
// Important documents can always be restored
if ($this->is_important) {
return true;
}
// Other documents can only be restored within 7 days
return $this->deleted_at->gte(now()->subDays(7));
}
}
Advanced Recovery Systems
Staged Recovery Process
Implement a multi-stage recovery process with approval workflows:
// app/Models/RecoveryRequest.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class RecoveryRequest extends Model
{
protected $fillable = [
'recoverable_type',
'recoverable_id',
'requested_by',
'approved_by',
'reason',
'status',
'approved_at',
'rejected_at',
'completed_at'
];
protected $casts = [
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
'completed_at' => 'datetime'
];
const STATUS_PENDING = 'pending';
const STATUS_APPROVED = 'approved';
const STATUS_REJECTED = 'rejected';
const STATUS_COMPLETED = 'completed';
public function recoverable(): MorphTo
{
return $this->morphTo()->withTrashed();
}
public function requestedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by');
}
public function approvedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
public function approve(User $approver): bool
{
if ($this->status !== self::STATUS_PENDING) {
return false;
}
$this->update([
'status' => self::STATUS_APPROVED,
'approved_by' => $approver->id,
'approved_at' => now()
]);
return true;
}
public function reject(User $approver, string $reason = null): bool
{
if ($this->status !== self::STATUS_PENDING) {
return false;
}
$this->update([
'status' => self::STATUS_REJECTED,
'approved_by' => $approver->id,
'rejected_at' => now(),
'reason' => $reason
]);
return true;
}
public function execute(): bool
{
if ($this->status !== self::STATUS_APPROVED) {
return false;
}
$recoverable = $this->recoverable;
if (!$recoverable || !$recoverable->trashed()) {
return false;
}
// Restore the record
$recoverable->restore();
$this->update([
'status' => self::STATUS_COMPLETED,
'completed_at' => now()
]);
return true;
}
}
Recovery Service with Validation
// app/Services/RecoveryService.php
<?php
namespace App\Services;
use App\Models\RecoveryRequest;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class RecoveryService
{
public function requestRecovery(
Model $model,
User $requester,
string $reason
): RecoveryRequest {
if (!$model->trashed()) {
throw new \InvalidArgumentException('Model is not soft deleted');
}
if (!$this->canRequestRecovery($model, $requester)) {
throw new \UnauthorizedException('User cannot request recovery for this model');
}
return DB::transaction(function () use ($model, $requester, $reason) {
return RecoveryRequest::create([
'recoverable_type' => get_class($model),
'recoverable_id' => $model->id,
'requested_by' => $requester->id,
'reason' => $reason,
'status' => RecoveryRequest::STATUS_PENDING
]);
});
}
public function bulkRestore(array $modelIds, string $modelClass, User $user): array
{
$results = [
'success' => [],
'failed' => [],
'unauthorized' => []
];
$models = $modelClass::withTrashed()
->whereIn('id', $modelIds)
->get();
foreach ($models as $model) {
try {
if (!$this->canRestoreDirectly($model, $user)) {
$results['unauthorized'][] = [
'id' => $model->id,
'reason' => 'Insufficient permissions'
];
continue;
}
if (!$model->trashed()) {
$results['failed'][] = [
'id' => $model->id,
'reason' => 'Model is not deleted'
];
continue;
}
$model->restore();
$results['success'][] = $model->id;
} catch (\Exception $e) {
$results['failed'][] = [
'id' => $model->id,
'reason' => $e->getMessage()
];
}
}
return $results;
}
public function scheduleAutoRestore(Model $model, \DateTimeInterface $restoreAt): void
{
if (!$model->trashed()) {
throw new \InvalidArgumentException('Model is not soft deleted');
}
\App\Jobs\RestoreModelJob::dispatch($model, $restoreAt)
->delay($restoreAt);
}
private function canRequestRecovery(Model $model, User $requester): bool
{
// Check if user owns the model
if (method_exists($model, 'user') && $model->user_id === $requester->id) {
return true;
}
// Check if user has admin permissions
if ($requester->hasRole('admin')) {
return true;
}
// Check if user has specific recovery permissions
return $requester->can('request-recovery', $model);
}
private function canRestoreDirectly(Model $model, User $user): bool
{
// Admins can restore directly
if ($user->hasRole('admin')) {
return true;
}
// Users can restore their own models within 24 hours
if (method_exists($model, 'user') &&
$model->user_id === $user->id &&
$model->deleted_at->gte(now()->subDay())) {
return true;
}
return false;
}
}
Comprehensive Audit and Versioning
Audit Trail for Deleted Records
// app/Models/AuditLog.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class AuditLog extends Model
{
protected $fillable = [
'auditable_type',
'auditable_id',
'user_id',
'action',
'old_values',
'new_values',
'ip_address',
'user_agent'
];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array'
];
const ACTION_CREATED = 'created';
const ACTION_UPDATED = 'updated';
const ACTION_DELETED = 'deleted';
const ACTION_RESTORED = 'restored';
const ACTION_FORCE_DELETED = 'force_deleted';
public function auditable(): MorphTo
{
return $this->morphTo()->withTrashed();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public static function logDeletion(Model $model, User $user = null): void
{
self::create([
'auditable_type' => get_class($model),
'auditable_id' => $model->id,
'user_id' => $user?->id ?? auth()->id(),
'action' => self::ACTION_DELETED,
'old_values' => $model->getOriginal(),
'new_values' => ['deleted_at' => now()],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent()
]);
}
public static function logRestoration(Model $model, User $user = null): void
{
self::create([
'auditable_type' => get_class($model),
'auditable_id' => $model->id,
'user_id' => $user?->id ?? auth()->id(),
'action' => self::ACTION_RESTORED,
'old_values' => ['deleted_at' => $model->getOriginal('deleted_at')],
'new_values' => ['deleted_at' => null],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent()
]);
}
}
Model Versioning with Soft Deletes
// app/Models/Traits/HasVersions.php
<?php
namespace App\Models\Traits;
use App\Models\ModelVersion;
use Illuminate\Database\Eloquent\Relations\MorphMany;
trait HasVersions
{
protected static function bootHasVersions(): void
{
static::updating(function ($model) {
$model->createVersion();
});
static::deleting(function ($model) {
if (!$model->isForceDeleting()) {
$model->createVersion('deleted');
}
});
static::restored(function ($model) {
$model->createVersion('restored');
});
}
public function versions(): MorphMany
{
return $this->morphMany(ModelVersion::class, 'versionable')
->orderBy('version_number', 'desc');
}
public function createVersion(string $action = 'updated'): ModelVersion
{
$lastVersion = $this->versions()->first();
$versionNumber = $lastVersion ? $lastVersion->version_number + 1 : 1;
return $this->versions()->create([
'version_number' => $versionNumber,
'action' => $action,
'data' => $this->getAttributes(),
'user_id' => auth()->id(),
'created_at' => now()
]);
}
public function getVersion(int $versionNumber): ?ModelVersion
{
return $this->versions()
->where('version_number', $versionNumber)
->first();
}
public function restoreToVersion(int $versionNumber): bool
{
$version = $this->getVersion($versionNumber);
if (!$version) {
return false;
}
$this->fill($version->data);
$this->save();
return true;
}
}
Advanced Reporting and Analytics
Deletion Analytics Service
// app/Services/DeletionAnalyticsService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DeletionAnalyticsService
{
public function getDeletionReport(string $modelClass, array $options = []): array
{
$startDate = $options['start_date'] ?? now()->subMonth();
$endDate = $options['end_date'] ?? now();
$groupBy = $options['group_by'] ?? 'day';
$baseQuery = $modelClass::onlyTrashed()
->whereBetween('deleted_at', [$startDate, $endDate]);
return [
'summary' => $this->getDeletionSummary($baseQuery),
'timeline' => $this->getDeletionTimeline($baseQuery, $groupBy),
'by_user' => $this->getDeletionsByUser($baseQuery),
'recovery_stats' => $this->getRecoveryStats($modelClass, $startDate, $endDate),
'top_deleted' => $this->getTopDeletedRecords($baseQuery)
];
}
private function getDeletionSummary($query): array
{
$total = $query->count();
$thisWeek = $query->where('deleted_at', '>=', now()->startOfWeek())->count();
$lastWeek = $query->whereBetween('deleted_at', [
now()->subWeek()->startOfWeek(),
now()->subWeek()->endOfWeek()
])->count();
$weekChange = $lastWeek > 0 ? (($thisWeek - $lastWeek) / $lastWeek) * 100 : 0;
return [
'total_deleted' => $total,
'this_week' => $thisWeek,
'last_week' => $lastWeek,
'week_change_percent' => round($weekChange, 2)
];
}
private function getDeletionTimeline($query, string $groupBy): array
{
$dateFormat = match ($groupBy) {
'hour' => '%Y-%m-%d %H:00:00',
'day' => '%Y-%m-%d',
'week' => '%Y-%u',
'month' => '%Y-%m',
default => '%Y-%m-%d'
};
return $query
->selectRaw("DATE_FORMAT(deleted_at, '{$dateFormat}') as period, COUNT(*) as count")
->groupBy('period')
->orderBy('period')
->get()
->toArray();
}
private function getDeletionsByUser($query): array
{
return $query
->join('audit_logs', function ($join) {
$join->on('audit_logs.auditable_id', '=', DB::raw('CAST(posts.id AS CHAR)'))
->where('audit_logs.auditable_type', '=', get_class($query->getModel()))
->where('audit_logs.action', '=', 'deleted');
})
->join('users', 'audit_logs.user_id', '=', 'users.id')
->selectRaw('users.name, users.email, COUNT(*) as deletion_count')
->groupBy('users.id', 'users.name', 'users.email')
->orderBy('deletion_count', 'desc')
->limit(10)
->get()
->toArray();
}
private function getRecoveryStats(string $modelClass, Carbon $startDate, Carbon $endDate): array
{
$deleted = $modelClass::onlyTrashed()
->whereBetween('deleted_at', [$startDate, $endDate])
->count();
$recovered = $modelClass::withTrashed()
->whereNotNull('deleted_at')
->whereBetween('deleted_at', [$startDate, $endDate])
->whereNull('deleted_at') // Currently not deleted (recovered)
->count();
$recoveryRate = $deleted > 0 ? ($recovered / $deleted) * 100 : 0;
return [
'total_deleted' => $deleted,
'total_recovered' => $recovered,
'recovery_rate_percent' => round($recoveryRate, 2),
'currently_deleted' => $deleted - $recovered
];
}
private function getTopDeletedRecords($query): array
{
return $query
->select('id', 'title', 'deleted_at')
->orderBy('deleted_at', 'desc')
->limit(10)
->get()
->toArray();
}
public function getDataRetentionReport(): array
{
$models = [
'App\\Models\\Post',
'App\\Models\\Comment',
'App\\Models\\User'
];
$report = [];
foreach ($models as $modelClass) {
$oldestDeleted = $modelClass::onlyTrashed()
->orderBy('deleted_at')
->first();
$countByAge = $modelClass::onlyTrashed()
->selectRaw('
COUNT(*) as total,
SUM(CASE WHEN deleted_at >= ? THEN 1 ELSE 0 END) as last_30_days,
SUM(CASE WHEN deleted_at >= ? THEN 1 ELSE 0 END) as last_90_days,
SUM(CASE WHEN deleted_at >= ? THEN 1 ELSE 0 END) as last_year
', [
now()->subDays(30),
now()->subDays(90),
now()->subYear()
])
->first();
$report[class_basename($modelClass)] = [
'oldest_deletion' => $oldestDeleted?->deleted_at,
'total_deleted' => $countByAge->total,
'last_30_days' => $countByAge->last_30_days,
'last_90_days' => $countByAge->last_90_days,
'last_year' => $countByAge->last_year,
'older_than_year' => $countByAge->total - $countByAge->last_year
];
}
return $report;
}
}
Dashboard Controller for Soft Delete Management
// app/Http/Controllers/Admin/SoftDeleteDashboardController.php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\DeletionAnalyticsService;
use App\Services\RecoveryService;
use App\Models\RecoveryRequest;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class SoftDeleteDashboardController extends Controller
{
public function __construct(
private DeletionAnalyticsService $analyticsService,
private RecoveryService $recoveryService
) {}
public function index()
{
$pendingRecoveries = RecoveryRequest::with(['recoverable', 'requestedBy'])
->where('status', RecoveryRequest::STATUS_PENDING)
->orderBy('created_at', 'desc')
->paginate(10);
return view('admin.soft-deletes.dashboard', compact('pendingRecoveries'));
}
public function analytics(Request $request): JsonResponse
{
$modelClass = $request->get('model', 'App\\Models\\Post');
$startDate = $request->date('start_date') ?? now()->subMonth();
$endDate = $request->date('end_date') ?? now();
$groupBy = $request->get('group_by', 'day');
$report = $this->analyticsService->getDeletionReport($modelClass, [
'start_date' => $startDate,
'end_date' => $endDate,
'group_by' => $groupBy
]);
return response()->json($report);
}
public function retentionReport(): JsonResponse
{
$report = $this->analyticsService->getDataRetentionReport();
return response()->json($report);
}
public function approveRecovery(Request $request, RecoveryRequest $recoveryRequest): JsonResponse
{
$approved = $recoveryRequest->approve(auth()->user());
if (!$approved) {
return response()->json([
'error' => 'Cannot approve this recovery request'
], 422);
}
// Auto-execute if it's a simple recovery
if ($request->boolean('auto_execute', true)) {
$recoveryRequest->execute();
}
return response()->json([
'message' => 'Recovery request approved successfully'
]);
}
public function rejectRecovery(Request $request, RecoveryRequest $recoveryRequest): JsonResponse
{
$request->validate([
'reason' => 'required|string|max:500'
]);
$rejected = $recoveryRequest->reject(auth()->user(), $request->reason);
if (!$rejected) {
return response()->json([
'error' => 'Cannot reject this recovery request'
], 422);
}
return response()->json([
'message' => 'Recovery request rejected successfully'
]);
}
public function bulkRestore(Request $request): JsonResponse
{
$request->validate([
'model_class' => 'required|string',
'ids' => 'required|array',
'ids.*' => 'integer'
]);
$results = $this->recoveryService->bulkRestore(
$request->ids,
$request->model_class,
auth()->user()
);
return response()->json($results);
}
}
Background Jobs for Cleanup
Automated Cleanup Jobs
// app/Jobs/CleanupOldSoftDeletesJob.php
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CleanupOldSoftDeletesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private string $modelClass,
private int $daysOld,
private bool $forceDelete = true
) {}
public function handle(): void
{
$cutoffDate = now()->subDays($this->daysOld);
$query = $this->modelClass::onlyTrashed()
->where('deleted_at', '<', $cutoffDate);
$count = $query->count();
if ($count === 0) {
Log::info("No old soft deletes found for {$this->modelClass}");
return;
}
if ($this->forceDelete) {
$query->forceDelete();
Log::info("Force deleted {$count} old records from {$this->modelClass}");
} else {
// Just log what would be deleted
Log::info("Found {$count} old soft deleted records in {$this->modelClass} older than {$this->daysOld} days");
}
}
}
Recovery Request Processing Job
// app/Jobs/ProcessRecoveryRequestJob.php
<?php
namespace App\Jobs;
use App\Models\RecoveryRequest;
use App\Notifications\RecoveryRequestProcessed;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessRecoveryRequestJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private RecoveryRequest $recoveryRequest
) {}
public function handle(): void
{
if ($this->recoveryRequest->status !== RecoveryRequest::STATUS_APPROVED) {
return;
}
$success = $this->recoveryRequest->execute();
if ($success) {
$this->recoveryRequest->requestedBy->notify(
new RecoveryRequestProcessed($this->recoveryRequest, true)
);
} else {
$this->recoveryRequest->update([
'status' => RecoveryRequest::STATUS_PENDING,
'notes' => 'Failed to execute recovery - model may no longer exist'
]);
$this->recoveryRequest->requestedBy->notify(
new RecoveryRequestProcessed($this->recoveryRequest, false)
);
}
}
}
Testing Soft Delete Functionality
Comprehensive Test Suite
// tests/Feature/SoftDeleteTest.php
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use App\Services\RecoveryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SoftDeleteTest extends TestCase
{
use RefreshDatabase;
public function test_soft_delete_marks_record_as_deleted()
{
$post = Post::factory()->create();
$post->delete();
$this->assertSoftDeleted($post);
$this->assertNotNull($post->fresh()->deleted_at);
}
public function test_soft_deleted_records_excluded_from_queries()
{
$post = Post::factory()->create();
$post->delete();
$this->assertCount(0, Post::all());
$this->assertCount(1, Post::withTrashed()->get());
}
public function test_restore_brings_back_soft_deleted_record()
{
$post = Post::factory()->create();
$post->delete();
$post->restore();
$this->assertNull($post->fresh()->deleted_at);
$this->assertCount(1, Post::all());
}
public function test_cascading_soft_deletes()
{
$user = User::factory()->create();
$posts = Post::factory()->count(3)->create(['user_id' => $user->id]);
$user->delete();
$this->assertSoftDeleted($user);
foreach ($posts as $post) {
$this->assertSoftDeleted($post->fresh());
}
}
public function test_recovery_service_creates_recovery_request()
{
$user = User::factory()->create();
$post = Post::factory()->create();
$post->delete();
$recoveryService = app(RecoveryService::class);
$request = $recoveryService->requestRecovery($post, $user, 'Accidental deletion');
$this->assertDatabaseHas('recovery_requests', [
'recoverable_type' => Post::class,
'recoverable_id' => $post->id,
'requested_by' => $user->id,
'status' => 'pending'
]);
}
public function test_bulk_restore_processes_multiple_records()
{
$user = User::factory()->create();
$posts = Post::factory()->count(5)->create();
foreach ($posts as $post) {
$post->delete();
}
$recoveryService = app(RecoveryService::class);
$results = $recoveryService->bulkRestore(
$posts->pluck('id')->toArray(),
Post::class,
$user
);
$this->assertCount(5, $results['success']);
$this->assertCount(0, $results['failed']);
foreach ($posts as $post) {
$this->assertNull($post->fresh()->deleted_at);
}
}
public function test_conditional_soft_delete_respects_business_rules()
{
$importantPost = Post::factory()->create(['is_important' => true]);
$regularPost = Post::factory()->create(['is_important' => false, 'created_at' => now()->subDays(60)]);
$importantPost->delete();
$regularPost->delete();
// Important post should be soft deleted
$this->assertSoftDeleted($importantPost);
// Old regular post should be force deleted
$this->assertDatabaseMissing('posts', ['id' => $regularPost->id]);
}
}
Related Posts
For more insights into Laravel data management and advanced patterns, explore these related articles:
- Laravel Model Observers: Reactive Programming Patterns
- Laravel API Resources: Transform Your Data Like a Pro
- Event Sourcing with Laravel
Conclusion
Soft deletes in Laravel provide a powerful foundation for building resilient applications with comprehensive data management capabilities. By implementing advanced patterns like staged recovery, audit trails, and intelligent cleanup systems, you can create applications that handle data lifecycle management with confidence.
Key principles for effective soft delete implementation:
- Strategic Implementation: Use soft deletes where data recovery and audit trails are important
- Cascading Logic: Implement proper cascading for related models
- Recovery Workflows: Build staged recovery processes for sensitive data
- Comprehensive Auditing: Track all deletion and restoration activities
- Automated Cleanup: Implement background jobs for data retention policies
- Performance Considerations: Index deleted_at columns and monitor query performance
Soft deletes excel at providing safety nets for accidental deletions while maintaining data integrity and enabling comprehensive reporting. When combined with proper versioning, audit trails, and recovery workflows, they create robust systems that give users confidence in data management operations.
Remember that soft deletes are not always the right solution—consider the trade-offs between data safety and storage costs, and implement cleanup policies that align with your business requirements and compliance needs.
Add Comment
No comments yet. Be the first to comment!