مقاله

Soft Delete در Laravel — حذف نرم، بازیابی و forceDelete

Soft Delete در Laravel — حذف نرم، بازیابی و forceDelete

SoftDeletes trait، deleted_at، restore، withTrashed و trash در پنل ادمین — حذف امن بدون از دست دادن داده.

«پست را حذف کن» در CMS یعنی معمولاً «از سایت پنهان کن، نه از دیتابیس پاک کن». Soft Delete با ستون deleted_at همین را می‌دهد — audit trail، بازیابی اشتباه کاربر، و compliance. در این پورتفولیو Post، Category و Tag همه SoftDeletes دارند.

خلاصه: با trait SoftDeletes و ستون nullable deleted_at، متد delete() رکورد را پاک نمی‌کند؛ فقط زمان حذف را در deleted_at می‌نویسد و Eloquent از آن به بعد رکورد را در queryهای عادی نشان نمی‌دهد. با withTrashed() و onlyTrashed() رکوردهای حذف‌شده را می‌بینید، با restore() برمی‌گردانید و فقط forceDelete() ردیف را واقعاً از دیتابیس پاک می‌کند.

Soft Delete در برابر حذف دائمی

  • حذف دائمی (hard delete): ردیف با DELETE از جدول پاک می‌شود و بدون backup راه برگشتی از داخل برنامه نیست.
  • Soft Delete: ردیف در جدول می‌ماند و فقط deleted_at مقدار می‌گیرد. برنامه آن را «حذف‌شده» در نظر می‌گیرد، ولی داده هنوز وجود دارد و قابل بازیابی است.

Soft Delete جایگزین backup نیست. اگر دیتابیس خراب شود، یک migration اشتباه اجرا شود یا کسی forceDelete() بزند، فقط backup واقعی کمک می‌کند. Soft Delete برای برگرداندن حذف‌های عادی داخل خود برنامه است، مثلاً وقتی ویرایشگر اشتباهی یک پست را حذف کرده.

فعال‌سازی

use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}

trait به‌تنهایی کافی نیست؛ جدول باید ستون deleted_at داشته باشد. اگر ستون نباشد، اولین delete() یا query با خطای SQL روبه‌رو می‌شود.

// migration ساخت جدول یا migration جدا برای جدول موجود
Schema::table('posts', function (Blueprint $table) {
    $table->softDeletes(); // ستون timestamp nullable به نام deleted_at
});

// متد down
Schema::table('posts', function (Blueprint $table) {
    $table->dropSoftDeletes();
});

رفتار پیش‌فرض

Post::all() و query عادی رکوردهای حذف‌شده را نمی‌بینند — global scope خودکار.

trait یک global scope به نام SoftDeletingScope روی model ثبت می‌کند که به هر query Eloquent شرط deleted_at is null را اضافه می‌کند. این شرط روی find()، where()، paginate()، count() و relationها هم اعمال می‌شود. Laravel شرط‌های شما را هنگام اعمال scope گروه‌بندی می‌کند، پس orWhere باعث نمی‌شود رکوردهای حذف‌شده برگردند.

این scope فقط روی Eloquent کار می‌کند. queryهای DB::table('posts')، SQL خام و joinهایی که جدول posts را از model دیگری صدا می‌زنند شرط deleted_at را خودکار ندارند.

حذف و بازیابی

$post->delete();        // soft
$post->restore();       // بازیابی
$post->forceDelete();   // حذف واقعی از DB
$post->trashed();                 // true اگر deleted_at پر باشد

Post::destroy([1, 2, 3]);        // soft delete برای هر model
Post::forceDestroy([4]);         // حذف دائمی با id

Post::onlyTrashed()
    ->where('category_id', $categoryId)
    ->restore();                 // بازیابی گروهی در یک query

متدهای model مثل $post->delete() و $post->restore() رویدادهای model را اجرا می‌کنند: deleting/deleted و trashed هنگام حذف نرم، restoring/restored هنگام بازیابی و forceDeleting/forceDeleted هنگام حذف دائمی. عملیات گروهی روی query مثل Post::where(...)->delete() یا ->restore() مستقیماً یک UPDATE می‌زنند و رویداد هیچ modelی اجرا نمی‌شود. اگر cleanup یا log در Observer دارید، این تفاوت مهم است.

forceDelete و ریسک‌هایش

  • بعد از forceDelete() رکورد از داخل برنامه قابل بازیابی نیست.
  • اگر foreign key با cascadeOnDelete() دارید، دیتابیس رکوردهای وابسته را هم پاک می‌کند، حتی اگر آن‌ها هم SoftDeletes داشته باشند.
  • فایل‌ها و mediaی مرتبط خودکار پاک نمی‌شوند؛ اگر لازم است، در forceDeleted مدیریتشان کنید.
  • دکمهٔ «حذف دائمی» در پنل ادمین را پشت تأیید دوم و Policy جدا بگذارید و فقط به نقش‌های محدود بدهید.

Query با trashed

Post::withTrashed()->find($id);
Post::onlyTrashed()->paginate(20);
Post::withTrashed()->where('slug', $slug)->first();
  • withTrashed(): رکوردهای عادی و حذف‌شده با هم.
  • onlyTrashed(): فقط رکوردهای حذف‌شده؛ مناسب صفحهٔ سطل زباله.
  • withoutTrashed(): صریحاً فقط رکوردهای حذف‌نشده؛ معمولاً بعد از یک withTrashed() شرطی استفاده می‌شود.

route model binding به‌صورت پیش‌فرض برای رکورد حذف‌شده 404 برمی‌گرداند. برای routeی مثل بازیابی که باید رکورد حذف‌شده را پیدا کند، withTrashed() را روی خود route بگذارید:

Route::post('/admin/posts/{post}/restore', [PostController::class, 'restore'])
    ->withTrashed();

مراقب withoutGlobalScopes() باشید: این متد scope حذف نرم را هم برمی‌دارد و رکوردهای حذف‌شده دوباره در نتیجه ظاهر می‌شوند.

رابطه و soft delete

اگر parent soft delete شود، children چه؟ خودتان cascade منطقی تعریف کنید — Laravel خودکار cascade نمی‌دهد مگر در migration foreign key.

// در Observer deleting
$post->comments()->delete(); // soft هر کدام SoftDeletes داشته باشد

توجه کنید foreign key با cascadeOnDelete() فقط روی DELETE واقعی دیتابیس عمل می‌کند. Soft Delete یک UPDATE است، پس cascade دیتابیس هیچ اثری روی childها ندارد. بازیابی هم خودکار cascade نمی‌شود؛ اگر childها را همراه parent حذف نرم کرده‌اید، در رویداد restored آن‌ها را هم برگردانید.

relationی که به یک model با SoftDeletes اشاره می‌کند، رکوردهای حذف‌شده را نشان نمی‌دهد. اگر برای گزارش یا تاریخچه به آن‌ها نیاز دارید، روی relation هم withTrashed() بزنید، مثلاً $user->posts()->withTrashed()->get(). برای جزئیات تعریف relationها راهنمای روابط Eloquent را ببینید.

unique با soft delete

slug unique بعد از soft delete conflict می‌دهد — راه‌حل: unique composite با deleted_at یا append timestamp به slug هنگام delete.

دو لایه را جدا ببینید:

  • Validation: اگر می‌خواهید slug رکورد حذف‌شده آزاد حساب شود، از Rule::unique('posts', 'slug')->withoutTrashed() استفاده کنید.
  • Index دیتابیس: در MySQL/MariaDB مقدارهای NULL در unique index تکراری حساب نمی‌شوند. یعنی index ترکیبی (slug, deleted_at) اجازه می‌دهد دو رکورد زندهٔ با slug یکسان (هر دو با deleted_at برابر NULL) ثبت شوند. اگر یکتایی رکوردهای زنده برایتان حیاتی است، روش تغییر slug هنگام حذف یا قید سطح برنامه را ترجیح دهید و رفتار دیتابیس خودتان را تست کنید.

Filament / admin trash

لیست onlyTrashed + action restore و forceDelete — UX حرفه‌ای.

در صفحهٔ سطل زباله، حذف دائمی را به نقش محدودتری از بازیابی بدهید. اگر رکوردهای حذف‌شدهٔ قدیمی باید بعد از مدتی پاک شوند، traitهای Prunable یا MassPrunable همراه دستور php artisan model:prune این کار را زمان‌بندی‌شده انجام می‌دهند؛ ولی همان ریسک‌های forceDelete را دارند.

تفاوت Soft Delete و status=archived

archived هنوز در query عادی دیده می‌شود با فیلتر؛ soft delete از query جدا می‌شود. گاهی هر دو لازم است.

Soft Delete جای transaction را هم نمی‌گیرد: اگر حذف یک رکورد و ثبت تغییرات وابسته باید همه با هم موفق یا همه با هم لغو شوند، آن را داخل یک transaction بگذارید. Transaction در Laravel این بخش را توضیح می‌دهد.

تست soft delete و restore

public function test_admin_can_soft_delete_and_restore_a_post(): void
{
    $post = Post::factory()->create();

    $post->delete();
    $this->assertSoftDeleted($post);
    $this->assertNull(Post::find($post->id));
    $this->assertNotNull(Post::withTrashed()->find($post->id));

    $post->restore();
    $this->assertNotSoftDeleted($post);

    $post->forceDelete();
    $this->assertModelMissing($post);
}

assertSoftDeleted بررسی می‌کند ردیف هنوز وجود دارد و deleted_at پر است؛ assertModelMissing بررسی می‌کند ردیف واقعاً پاک شده. اگر برای تست‌ها رکورد حذف‌شده لازم دارید، در factory یک state با مقدار deleted_at بسازید؛ Factory و Seeder پیشرفته الگوی stateها را نشان می‌دهد.

اشتباهات رایج

  • اضافه کردن trait بدون migration ستون deleted_at.
  • فرض اینکه بعد از delete() ردیف از جدول رفته؛ گزارش‌های SQL خام، export و queryهای DB::table رکوردهای حذف‌شده را هم می‌شمارند.
  • استفاده از withoutGlobalScopes() برای برداشتن یک scope دیگر و برگشتن ناخواستهٔ رکوردهای حذف‌شده؛ به‌جایش withoutGlobalScope(SomeScope::class) بزنید.
  • انتظار اجرای Observer در حذف یا بازیابی گروهی روی query.
  • قانون unique که slug رکوردهای حذف‌شده را هم در نظر می‌گیرد و کاربر را با خطای «تکراری» گیج می‌کند.
  • فراموش کردن withTrashed() روی route بازیابی و گرفتن 404.

performance

index روی deleted_at برای جدول بزرگ. بهینه‌سازی.

اینکه index کمک می‌کند یا نه به queryها و داده‌های شما بستگی دارد؛ قبل از اضافه کردنش، query واقعی را با EXPLAIN بررسی کنید.

جمع‌بندی

Soft delete برای entityهای قابل بازیابی ضروری است. global scope، Observer برای cleanup.

ترتیب کار: migration ستون deleted_at، trait روی model، تصمیم دربارهٔ childها و قانون unique، withTrashed() روی route بازیابی، محدود کردن forceDelete و نوشتن تست برای حذف، بازیابی و حذف دائمی.

CMS سفارشی

اشتراک‌گذاری