dcat/laravel-softdeletes
dcat/laravel-softdeletes adds enhanced soft delete support to Laravel Eloquent models. Cascade deletes and restores across related records, keep data consistent, and avoid unique constraint conflicts from trashed rows, all through simple traits with minimal configuration.
The package swaps Laravel's deleted_at-column soft deletes for an archive-table approach: when you delete a model, the row is copied into a shared soft_deletes table and physically removed from its own table. The original table stays clean, which is the whole point — you can put unique indexes on email, phone, slug, etc. without deleted rows blocking re-registration, and the hot table doesn't carry bloat plus a deleted_at index.
Install and scaffold
composer require dcat/laravel-softdeletes
# publishes the archive-table migration + config
php artisan vendor:publish --provider="Dcat\LaravelSoftDeletes\SoftDeletesServiceProvider"
php artisan migrate
Adopt it on one model first — do not convert the whole app at once.
namespace App\Models;
use Dcat\LaravelSoftDeletes\SoftDeletes; // NOT Illuminate\Database\Eloquent\SoftDeletes
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
use SoftDeletes;
}
Then drop the now-unused column and add the unique index you actually wanted:
Schema::table('users', function (Blueprint $table) {
$table->dropSoftDeletes(); // removes deleted_at
$table->unique('email');
});
First use case to prove it out: a users table where a deleted account's email currently blocks re-registration. Delete a user, confirm the row vanished from users and appeared in soft_deletes, then register the same email again. If that works, the integration is sound.
Where to look first, in order:
src/SoftDeletes.php — the trait. Confirm exactly which methods it overrides (delete, forceDelete, restore, runSoftDelete) and which query scopes it does or does not provide. Fifteen minutes here saves a day of surprises.model_type, model_id, and the payload column) drive every query you'll write against it.Default queries need no changes. Because the row is gone from the main table, User::all() naturally returns only live users — there is no global scope to fight. That's the nicest property of this design.
Trashed data lives in a second table, so treat it as one. Build an explicit archive model or repository over the soft_deletes table rather than expecting withTrashed() semantics:
// A "recycle bin" listing — polymorphic, so filter by type
$archived = DB::table('soft_deletes')
->where('model_type', User::class)
->orderByDesc('id')
->paginate(50);
For an admin UI (Dcat Admin, Nova, Filament) this is actually cleaner than Laravel's built-in trash filter: one dedicated "Deleted Records" page, one resource, restore and purge buttons per row. Since the same author maintains Dcat Admin, the trait slots into a Dcat grid without ceremony — you just point the CRUD at the archive table instead of the model.
Restoration is a re-insert, so treat it as a business operation, not a toggle. Wrap it and handle the collision case explicitly:
DB::transaction(function () use ($archived) {
// guard: has someone taken the unique key since?
abort_if(User::where('email', $archived->data['email'])->exists(), 409);
User::create(json_decode($archived->data, true));
DB::table('soft_deletes')->where('id', $archived->id)->delete();
});
Cascade by hand in the deleting event. There is no cascade from the database once the parent row is physically gone — and if you do have ON DELETE CASCADE foreign keys, soft-deleting a parent will hard-delete its children. Pick one:
protected static function booted()
{
static::deleting(function (self $user) {
// archive children with the parent, in the same transaction
$user->posts()->get()->each->delete();
});
}
Use the archive table as an audit log for free. Add deleted_by (or a causer column) to the published migration. You now have "who deleted what, when" with zero extra tables — a genuine argument for this package over native soft deletes.
Prune on a schedule. The archive is append-only and nothing cleans it up:
// App\Console\Kernel
$schedule->call(fn () => DB::table('soft_deletes')
->where('created_at', '<', now()->subDays(90))->delete())
->daily();
Migrate incrementally. Convert high-churn, unique-constrained tables first (users, orders, subscriptions). Leave low-volume tables on native soft deletes — mixing the two approaches across models is fine, since the mechanism is per-model.
composer require to refuse on modern Laravel; budget for a fork or a --ignore-platform-reqs trial, and re-test the trait against the current Eloquent internals before trusting it.query()->delete() vs destroy(). Eloquent's mass-delete path issues raw SQL and bypasses model events. Under native soft deletes the global scope rewrites it; here it is very likely a hard delete. Read the trait, then standardise on Model::destroy() / ->each->delete() everywhere, and grep your codebase for ->delete() on builders before converting a model.withTrashed() / onlyTrashed() / assertSoftDeleted() are casualties. Anything assuming one table — joins to the parent, ->trashed(), assertSoftDeleted($user) — needs rewriting against the archive table. Write a small assertArchived() test helper instead.NOT NULL column without a default after the record was archived, the re-insert throws. Keep restore windows short and give new columns defaults.model_id, plus maybe deleted_by) to the archive table and live with the rest.deleted_at residue. After conversion, clean up $dates/$casts, any whereNotNull('deleted_at') filters, validation rules, and model factories that set it.Illuminate\...\SoftDeletes alongside will produce duplicate global scopes and confusing behaviour.DB::listen() and watch for the insert/delete pair on a single delete(). If you only see the DELETE, the trait isn't hooked — usually a forgotten use statement or a stale cached trait map (composer dump-autoload).vendor/ — the package is effectively unmaintained, so plan for ownership.How can I help you explore Laravel packages today?