illuminatech/db-safedelete
Illuminatech/db-safedelete adds safe, reversible deletion to Laravel Eloquent models. Instead of permanently removing records, it marks them as deleted, supports restoring them, and helps keep related data and queries consistent. Useful for audit-friendly apps that need recoverable deletes without c...
Install the package and give the model both soft-delete plumbing and the package trait:
composer require illuminatech/db-safedelete
// database/migrations/...
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
$table->softDeletes(); // required: the fallback writes deleted_at
});
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminatech\DbSafeDelete\SafeDelete;
class Product extends Model
{
use SoftDeletes;
use SafeDelete;
}
Where to look first: the package README for the exact trait namespace/method names in your installed version, then the trait source itself — it is small, and the protected failure-detection method is the main extension point.
First use case: a catalog/admin delete button. Deleting an unreferenced product should actually remove the row; deleting one referenced by orders should quietly soft-delete instead of throwing a foreign-key error:
$product->delete();
if ($product->trashed()) {
return back()->with('status', 'Product archived (still referenced).');
}
return back()->with('status', 'Product permanently removed.');
trashed() is the reliable way to know which path ran: a successful hard delete leaves deleted_at null and exists false; the fallback leaves deleted_at set and exists true.
Standard resource destroy action. Keep controllers dumb; let the model decide:
public function destroy(Product $product)
{
$product->delete();
return redirect()->route('products.index')
->with('status', $product->trashed() ? 'Archived' : 'Deleted');
}
Explicit purge. When an admin really means "remove it," call forceDelete() directly — the trait's fallback is only on the normal delete path, so forceDelete() will surface the constraint exception (or cascade):
try {
$product->forceDelete();
} catch (\Illuminate\Database\QueryException $e) {
// still referenced; decide whether to block or report
}
Restore flow. Because the fallback is a normal soft delete, restore() and withTrashed() work unchanged. Pair delete/restore endpoints so an archived-then-unreferenced record can later be purged with forceDelete().
Customizing what counts as "still referenced." The trait catches the database exception and decides whether to fall back. Override its protected detection method in your model to narrow or broaden the behavior — for example, only treat integrity-constraint violations (MySQL 1451, PostgreSQL 23503, SQLite 787) as fallback-worthy, and rethrow connection/lock errors so they don't get silently converted into soft deletes. Verify the exact method name against the trait source in your installed version; it is the intended extension point.
Testing. Assert both branches with factories: one record with no dependents (expect exists === false), one with a dependent row and a restricting FK (expect trashed() === true). Use RefreshDatabase and enable FK enforcement in the test connection (see gotchas).
Queued/console cleanup. Deleting via a model instance works fine, but Product::query()->where(...)->delete() is a query-builder mass delete and bypasses the trait entirely. Chunk and call delete() per model when you need safe-delete semantics:
Product::where('discontinued', true)->chunkById(100, fn ($products) =>
$products->each->delete()
);
PRAGMA foreign_keys=ON. Without it there are no constraint violations to trigger, so everything hard-deletes. Enable it in config/database.php/test setup.ON DELETE CASCADE changes the outcome. If children cascade, the hard delete succeeds and children disappear — no fallback, no soft delete. SafeDelete only protects you against RESTRICT/NO ACTION style constraints.forceDeleting before falling back, then emit deleting/deleted from the soft-delete path. Observers that assume paired forceDeleting/forceDeleted events should be audited. Prefer deleted plus a trashed() check.delete() throwing to detect references will no longer see an error; inspect trashed() or the return state instead.sku/email remains occupied. Use partial/conditional unique indexes where appropriate.SoftDeletes and SafeDelete on the same model. If your Laravel version causes a method conflict, resolve it with insteadof and confirm the package trait's delete hook wins.How can I help you explore Laravel packages today?