Method delete() yang kita pakai di chapter Menghapus Data mengeksekusi DELETE sungguhan: barisnya benar-benar pergi dari database dan tidak ada jalan kembali. Banyak aplikasi justru membutuhkan “hapus” yang bisa dibatalkan — user salah menekan tombol, data masih dipakai laporan historis, atau auditor menuntut jejak lengkap. Soft delete menyelesaikan kebutuhan itu dengan cara sederhana: baris tidak dihapus, melainkan ditandai dengan timestamp di kolom deleted_at, lalu disembunyikan dari semua query Eloquent. Chapter ini membahas pemasangannya di migration dan model, cara memanggil kembali data terhapus, sampai jebakan unik constraint yang menyertainya.

Menambahkan Kolom deleted_at #

Kolom ini bisa hadir sejak tabel dibuat, tetapi lebih sering ditambahkan belakangan lewat migration terpisah. Artisan menyiapkannya seperti biasa:

bash
php artisan make:migration add_soft_deletes_to_posts_table --table=posts

Output yang Dihasilkan:

text
   INFO  Migration [database/migrations/2026_10_13_172958_add_soft_deletes_to_posts_table.php] created successfully.  

Isi migration lalu kita lengkapi dengan method softDeletes(), pasangan dari dropSoftDeletes() di down():

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('posts', function (Blueprint $table) {
            $table->softDeletes();
        });
    }

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

Method softDeletes() membuat kolom deleted_at bertipe timestamp yang bisa NULL. Menjalankan migration-nya menambahkan kolom tersebut ke tabel posts:

bash
php artisan migrate

Output yang Dihasilkan:

text

   INFO  Running migrations.  

  2026_10_13_172958_add_soft_deletes_to_posts_table .............. 1.81ms DONE

Trait SoftDeletes pada Model #

Kolom saja belum cukup — Eloquent perlu tahu bahwa tabel ini memakai soft delete. Trait SoftDeletes-lah yang mengaktifkan perilakunya:

php
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    /** @use HasFactory<PostFactory> */
    use HasFactory, SoftDeletes;

Trait ini menimpa delete(), menambahkan global scope yang menyaring baris terhapus dari setiap query, serta menyediakan method-method yang akan kita pakai sebentar lagi.

delete() Hanya Menandai, Bukan Menghapus #

Kita siapkan Budi dan tiga post miliknya, lalu menghapus post pertama sambil merekam query yang dijalankan Eloquent lewat query log :

php
$post = Post::first();

DB::enableQueryLog();
$post->delete();
foreach (DB::getQueryLog() as $q) {
    echo 'SQL: ', $q['query'], "\n";
}

echo 'Post::count(): ', Post::count(), "\n";
echo "DB::table('posts')->count(): ", DB::table('posts')->count(), "\n";
echo '$post->trashed(): ', $post->trashed() ? 'true' : 'false', "\n";
echo 'deleted_at: ', $post->deleted_at !== null ? 'terisi (bukan NULL)' : 'NULL', "\n";

Output yang Dihasilkan:

text
SQL: update "posts" set "deleted_at" = ?, "updated_at" = ? where "id" = ?
Post::count(): 2
DB::table('posts')->count(): 3
$post->trashed(): true
deleted_at: terisi (bukan NULL)

Perhatikan bahwa query yang dijalankan adalah UPDATE, bukan DELETE: delete() hanya mengisi deleted_at dengan waktu penghapusan. Buktinya ada di dua baris berikutnya — Post::count() kini bernilai dua, tetapi DB::table('posts')->count() masih menemukan tiga baris. Query Builder tidak mengerti soft delete, jadi hitungannya memperlihatkan isi tabel yang sesungguhnya: baris “terhapus” itu masih ada di database. Method trashed() pada model melaporkan status baris tersebut, dan deleted_at pada model kini terisi timestamp.

Query Default Menyembunyikan Data Terhapus #

Global scope dari trait memastikan baris bertanda tidak pernah muncul di query biasa:

php
echo 'Post::find(1): ', var_export(Post::find(1), true), "\n";
echo 'Post::withTrashed()->find(1)->judul: ', Post::withTrashed()->find(1)->judul, "\n";
echo 'Post::count(): ', Post::count(), "\n";
echo 'Post::withTrashed()->count(): ', Post::withTrashed()->count(), "\n";
echo 'Post::onlyTrashed()->count(): ', Post::onlyTrashed()->count(), "\n";

Output yang Dihasilkan:

text
Post::find(1): NULL
Post::withTrashed()->find(1)->judul: Belajar Soft Delete
Post::count(): 2
Post::withTrashed()->count(): 3
Post::onlyTrashed()->count(): 1

Tiga ekspresi ini membentuk spectrum pencarian. Query biasa seperti Post::find(1) mengembalikan NULL meskipun barisnya ada. withTrashed() melebarkan pencarian agar baris terhapus ikut, sedangkan onlyTrashed() justru membatasi pencarian ke baris terhapus saja — pas untuk halaman “tong sampah”.

restore() dan forceDelete() #

Dua method penutup menangani dua arah penuh siklusnya. restore() mengosongkan kembali deleted_at, sedangkan forceDelete() melakukan penghapusan fisik yang sebenarnya:

php
$post->restore();
echo 'Post::count(): ', Post::count(), "\n";
echo '$post->trashed(): ', $post->trashed() ? 'true' : 'false', "\n";
echo 'deleted_at: ', $post->fresh()->deleted_at !== null ? 'terisi' : 'NULL', "\n";

$post->delete();
$post->forceDelete();
echo 'Post::count(): ', Post::count(), "\n";
echo 'Post::withTrashed()->count(): ', Post::withTrashed()->count(), "\n";
echo "DB::table('posts')->count(): ", DB::table('posts')->count(), "\n";

Output yang Dihasilkan:

text
Post::count(): 3
$post->trashed(): false
deleted_at: NULL
Post::count(): 2
Post::withTrashed()->count(): 2
DB::table('posts')->count(): 2

Setelah restore(), jumlah baris kembali menjadi tiga dan deleted_at menjadi NULL — baris hidup kembali. Setelah forceDelete(), angka dari semua sudut pandang menyatu di dua: kali ini benar-benar tidak ada baris ketiga di database, dan hanya forceDelete() yang sanggup membersihkan baris bertanda secara permanen.

Kapan Soft Delete Layak Dipakai #

Soft delete paling terasa gunanya ketika data punya nilai historis (transaksi, komentar, dokumen) dan kesalahan penghapusan harus bisa ditarik kembali. Namun kemudahan itu dibayar dengan dua hal. Pertama, setiap query Eloquent kini membawa kondisi where deleted_at is null — biaya kecil yang menumpuk pada tabel besar, apalagi kolom deleted_at layak diberi index. Kedua, constraint database tidak tahu menahu soal soft delete, dan inilah jebakan yang paling sering menggigit:

php
$post = Post::factory()->create([
    'user_id' => $budi->id,
    'judul' => 'Belajar Soft Delete',
    'slug' => 'belajar-soft-delete',
]);
$post->delete();

try {
    Post::factory()->create([
        'user_id' => $budi->id,
        'judul' => 'Belajar Soft Delete (baru)',
        'slug' => 'belajar-soft-delete',
    ]);
    echo 'Post baru tersimpan tanpa masalah', "\n";
} catch (Illuminate\Database\QueryException $e) {
    echo 'Gagal menyimpan: ', substr($e->getMessage(), 0, 96), "...\n";
}

Output yang Dihasilkan:

text
Gagal menyimpan: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: posts.slug (Connec...

Pengguna baru tidak melihat post lama karena sudah “dihapus”, tetapi database menolak slug yang sama karena baris lamanya masih ada di tabel dan index unique tetap menghormatinya. Penyelesaiannya beragam — memasukkan deleted_at sebagai bagian dari kunci unik, membersihkan trashed secara berkala dengan onlyTrashed()->forceDelete(), atau memutuskan tabel tertentu memang tidak pantas memakai soft delete.

Tanda deleted_at yang berubah-ubah menunjukkan bahwa sebuah model sebenarnya punya siklus hidup — diciptakan, diubah, dihapus, dipulihkan. Laravel memungkinkan kita mengikat logika pada setiap momen siklus itu, dan itulah topik chapter berikutnya: model events.