Soft delete artinya menandai baris sebagai "sudah dihapus" dengan mengisi kolom deleted_at, tanpa benar-benar menghapus barisnya. Kebalikannya hard delete: DELETE, dan barisnya hilang permanen. Mirip Tempat Sampah di laptop. File yang kamu hapus gak tampil lagi di foldernya, tapi masih ada di Tempat Sampah dan bisa dikembalikan. Baru kalau Tempat Sampah dikosongkan, filenya hilang beneran.
🧪 Tebakan cepat: Budi menutup akunnya di Kedai Senja, dan akunnya di-soft delete. Tiga bulan kemudian dia daftar lagi dengan email yang sama,
[email protected]. KolomUNIQUE. Pendaftarannya berhasil atau error? Tebak dulu, jawabannya ada di bagian "Email yang sama gak bisa dipakai lagi".
Tabel yang kita pakai
Pelanggan Kedai Senja sekarang login pakai email, jadi tabel customers punya kolom email yang UNIQUE, ditambah kolom deleted_at:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
deleted_at TEXT -- NULL = aktif, berisi waktu = sudah dihapus
);
INSERT INTO customers (id, name, email) VALUES
(1, 'Budi', '[email protected]'),
(2, 'Siti', '[email protected]'),
(3, 'Andi', '[email protected]');
SQLite menyimpan waktu sebagai teks, jadi tipenya TEXT. Di MySQL dan PostgreSQL, kolom ini biasanya TIMESTAMP NULL.
Hard delete vs soft delete
-- hard delete: baris Budi hilang
DELETE FROM customers WHERE id = 1;
-- soft delete: baris Budi tetap ada, cuma ditandai
UPDATE customers SET deleted_at = '2026-10-08 09:15:00' WHERE id = 1;
-- daftar pelanggan aktif: Siti dan Andi
SELECT * FROM customers WHERE deleted_at IS NULL;
-- restore: Budi aktif lagi
UPDATE customers SET deleted_at = NULL WHERE id = 1;
deleted_atkosong (NULL) artinya aktif. Berisi waktu artinya sudah dihapus, sekaligus mencatat kapan.- Tulis
IS NULL, bukan= NULL. Di SQL,deleted_at = NULLgak pernah bernilai benar, jadi query-nya selalu kosong. - Soft delete sebenarnya cuma
UPDATE. Semua query yang membaca tabel ini harus ingat menambahkanWHERE deleted_at IS NULL.
Soft delete di Laravel
Tambahkan kolomnya di migration dengan $table->softDeletes();, lalu pasang trait SoftDeletes di model:
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model
{
use SoftDeletes;
}
Sejak itu, Eloquent menulis filternya sendiri:
| Kode Eloquent | SQL yang dikirim |
|---|---|
$budi->delete() |
UPDATE customers SET deleted_at = '...' WHERE id = 1 |
Customer::all() |
SELECT * FROM customers WHERE deleted_at IS NULL |
Customer::withTrashed()->get() |
SELECT * FROM customers |
Customer::onlyTrashed()->get() |
SELECT * FROM customers WHERE deleted_at IS NOT NULL |
$budi->restore() |
UPDATE customers SET deleted_at = NULL WHERE id = 1 |
$budi->forceDelete() |
DELETE FROM customers WHERE id = 1 |
Query yang lupa filter
Filter otomatis itu cuma milik Eloquent. Query builder dan SQL mentah gak tahu apa-apa soal SoftDeletes:
Customer::count(); // 2: Siti dan Andi
DB::table('customers')->count(); // 3: Budi ikut terhitung
Laporan "jumlah pelanggan" yang ditulis dengan DB::table atau SQL mentah akan diam-diam menghitung pelanggan yang sudah menutup akun. Di query seperti itu, tulis sendiri whereNull('deleted_at') atau WHERE deleted_at IS NULL.
Email yang sama gak bisa dipakai lagi
Budi sudah di-soft delete. Dia daftar lagi:
INSERT INTO customers (name, email) VALUES ('Budi', '[email protected]');
UNIQUE constraint failed: customers.email
Error. Itu jawaban tebakannya. Baris Budi yang lama masih ada di tabel, lengkap dengan [email protected], dan UNIQUE mengecek semua baris, termasuk yang sudah di-soft delete.
Solusinya partial unique index: index UNIQUE yang cuma berlaku untuk baris yang memenuhi WHERE. Hapus UNIQUE dari kolom email, lalu:
CREATE UNIQUE INDEX customers_email_active ON customers (email) WHERE deleted_at IS NULL;
- Sekarang email cuma harus unik di antara pelanggan aktif. Budi daftar lagi berhasil, dan tabelnya punya dua baris Budi: yang lama (terhapus) dan yang baru.
- Dua pelanggan aktif dengan email yang sama tetap ditolak. Me-restore Budi lama selagi Budi baru masih aktif juga ditolak, dengan error yang sama.
- Laravel gak punya method migration untuk partial index. Tulis lewat
DB::statement('CREATE UNIQUE INDEX ...'). Validasi form-nya juga perlu ikut:Rule::unique('customers')->withoutTrashed().
Archiving: pindahkan data lama
Soft delete menyimpan semuanya di tabel yang sama. Setelah bertahun-tahun, tabel orders bisa berisi jutaan pesanan lama yang hampir gak pernah dibuka, dan semua query di tabel utama ikut melambat. Archiving memindahkan data lama itu ke tabel terpisah:
BEGIN;
INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < '2024-01-01';
DELETE FROM orders WHERE created_at < '2024-01-01';
COMMIT;
orders_archivepunya kolom yang sama persis denganorders.BEGINdanCOMMITmembungkus keduanya jadi satu transaksi: kalauDELETE-nya gagal,INSERT-nya ikut dibatalkan, jadi gak ada pesanan yang hilang atau tercatat dua kali.- Tabel utama jadi kecil dan cepat. Data lama tetap ada kalau suatu saat dibutuhkan untuk laporan.
Pilih yang mana
| Kebutuhannya | Strategi |
|---|---|
| Data mungkin perlu dikembalikan (bisa di-restore) | soft delete |
| Perlu jejak kapan data dihapus (audit trail) | soft delete |
| Pengguna minta datanya dihapus (GDPR di Eropa, UU PDP di Indonesia) | hard delete |
| Hemat storage, data sementara seperti token reset password | hard delete |
| Data lama yang jarang diakses | archive |
Kalau ragu, soft delete pilihan yang paling aman: data yang sudah di-hard delete gak bisa dikembalikan.
📌 MySQL gak punya partial index
Partial index seperti di atas jalan di SQLite dan PostgreSQL. MySQL belum mendukungnya. Di MySQL, UNIQUE (email, deleted_at) juga gak menolong, karena MySQL menganggap setiap NULL berbeda, jadi dua Budi aktif bisa lolos. Cara yang biasa dipakai: kolom tambahan yang dihitung otomatis (generated column), isinya email kalau aktif dan NULL kalau terhapus, lalu UNIQUE di kolom itu.
⚠️ Jebakan yang sering ketemu
deleted_at = NULL. Selalu kosong. PakaiIS NULL.DB::table()atau SQL mentah. Gak ada filter otomatis, pelanggan yang sudah dihapus ikut muncul.UNIQUEbiasa di tabel dengan soft delete. Orang yang sama gak bisa daftar lagi. Pakai partial unique index.- Soft delete untuk data yang wajib dihapus. Kalau pengguna minta datanya dihapus, soft delete masih menyimpannya. Pakai
forceDelete(). - Teks di latihan. Kembalikan persis
"soft delete","hard delete"atau"archive", huruf kecil semua."jarang diakses"juga"archive".
TL;DR: Soft delete mengisi deleted_at alih-alih menghapus baris, jadi data bisa di-restore. Di Laravel, pasang use SoftDeletes; dan Eloquent otomatis menambahkan WHERE deleted_at IS NULL, tapi DB::table() dan SQL mentah gak. Baris yang di-soft delete tetap memegang nilai UNIQUE-nya, jadi pakai partial unique index ... WHERE deleted_at IS NULL. Hard delete untuk data yang wajib hilang (GDPR, UU PDP) atau sekadar hemat storage. Archive memindahkan data lama yang jarang diakses ke tabel terpisah, supaya tabel utama tetap kecil dan cepat.