sbamtr/laravel-auto-hard-deleter
Automatically hard delete soft-deleted Eloquent models after a configurable retention period. Add a trait to your models, set how long records should linger in the trash, and let Laravel's scheduler permanently purge them, with no manual cleanup jobs required.
This package is a small utility that hard-deletes soft-deleted Eloquent models once they've been trashed for longer than a configured retention window. It is thin glue around Laravel's SoftDeletes trait and a scheduled cleanup job — not a full data-retention framework. Treat it as a convenience, not a compliance tool.
Minimal setup
composer require sbamtr/laravel-auto-hard-deleter
README, likely auto-hard-deleter-config):
php artisan vendor:publish --provider="SiavashBamshadnia\AutoHardDeleter\AutoHardDeleterServiceProvider"
config/auto-hard-deleter.php and register the models you want purged with their retention in days. The shape is roughly a map of model class → number of days:
'models' => [
App\Models\Notification::class => 30,
App\Models\AuditLog::class => 365,
],
php artisan list | grep -i hard to confirm the exact signature — it's the only entry point) in app/Console/Kernel.php or routes/console.php:
$schedule->command('auto-hard-deleter:run')->dailyAt('03:00');
Where to look first
whereNotNull('deleted_at'), where('deleted_at', '<', now()->subDays($n))), how it chunks, and whether it force-deletes per model.First use case
The cleanest first win is throwaway rows that users can already restore: abandoned draft records, read notifications, expired invitations/coupons. Point the config at one expendable model, set the retention to something conservative (30–90 days), run php artisan auto-hard-deleter:run in staging with --pretend if supported, then wire up the scheduler. Only after that do you add models with real auditing value.
Config-driven registry as the single source of truth. The idiomatic use is: add a model to the config map, done. Resist the temptation to sprinkle hard-deletes elsewhere. Keep retention numbers in this file rather than hardcoding them in jobs so ops can tune them without a deploy.
Schedule it, don't event it. A natural instinct is to hook deleted and queue a delayed force-delete. Don't — that couples every ->delete() call to the package and loses the state if the queue is down. The package's scheduled sweep is the right pattern: idempotent, catch-up-friendly, and easy to verify. Run it nightly, off-peak, with withoutOverlapping() and onOneServer() if you run multiple app servers:
$schedule->command('auto-hard-deleter:run')
->dailyAt('03:00')
->withoutOverlapping()
->onOneServer();
Chunk and log per model. If the package doesn't already chunk, wrap it or fork the command. Large tables make a single DELETE ... WHERE deleted_at < ... a lock-heavy operation. Watch for per-model logging so you can see purge counts in your log aggregator — "how many notifications were purged last night" is a question you will be asked.
Per-tenant or per-environment overrides. The config is a plain PHP array, so read from env() or a tenant resolver:
'models' => [
App\Models\AuditLog::class => (int) env('AUDIT_LOG_RETENTION_DAYS', 365),
],
For multi-tenant apps you'll usually outgrow the config map fast — one tenant's 30 days is another's 365. In that case keep the package for the trivial models and write a tenant-aware command for the rest; don't fight the package's shape.
Testing. Use Carbon::setTestNow() / $this->travel() to age rows, then assert the command purges old ones and leaves fresh ones:
$this->travel(-31)->days();
$notification = Notification::factory()->create();
$notification->delete();
$this->artisan('auto-hard-deleter:run')->assertSuccessful();
$this->assertDatabaseMissing('notifications', ['id' => $notification->id]);
Also test the negative: a record soft-deleted 10 days ago with 30-day retention must survive. That's the assertion that catches a botched config.
Integration tip. Pair it with a forceDeleting observer on the important models to emit an audit trail (who was purged, by which command run), because the package itself almost certainly just calls forceDelete() and moves on.
It's archived with zero dependents. Treat it as reference code, not infrastructure. The realistic long-term move is to copy the command (60–100 lines) into app/Console/Commands/HardDeleteStaleSoftDeleted.php and drop the dependency. You get the same behaviour, you own the bug fixes, and you avoid the abandoned-package conversation at the next security review. If you do keep it, pin the exact version in composer.json.
Config cache bites. php artisan config:cache will bake your retention map in. Any change to retention requires config:clear (or a re-cache in deploy). If ops "changes the retention and nothing happens," this is why nine times out of ten.
Soft-delete column assumption. The package almost certainly filters on deleted_at. If any of your models override getDeletedAtColumn() or use a custom timestamp column (e.g. archived_at), it will silently never purge them — or worse, compare against the wrong column. Verify per model.
Bulk deletes bypass everything. Model::where(...)->delete() on a soft-deleting model does set deleted_at, so those rows will be swept — good. But raw DB::table()->delete() or ->forceDelete() calls skip the package entirely. Know which deletions in your codebase go through Eloquent.
Foreign keys and cascades. A forceDelete() is a real DELETE; ON DELETE CASCADE and RESTRICT both apply, unlike a soft delete. A model that soft-deletes fine can throw Integrity constraint violation when the package hard-deletes it. Test the purge path against a realistic DB, not just SQLite in CI.
Restore race. A user restoring a record microseconds before the sweep can still lose it — the transaction boundary is whatever the package does, and it likely doesn't re-check deleted_at inside a lock. For user-facing models (accounts, invoices) don't include them; only purge things where loss is acceptable.
Timezone drift. now()->subDays($n) uses app.timezone. If your DB stores UTC and your app timezone is local, retention can be off by hours — meaningful if you have any "delete after exactly 24 hours" expectations.
Extension points are thin. Don't expect an event or interface to hook. If you need per-model pre-purge hooks, write your own command and call forceDelete() inside a DB::transaction() after a deleting-style guard. That's also where you'd add a dry-run flag, which the original likely lacks or implements crudely.
Debugging checklist when a row isn't purging: model actually in the config map with matching FQCN; deleted_at non-null and older than retention in the app's timezone; config cache cleared; scheduler actually running (php artisan schedule:list); no deleted_at column override; and the row isn't held by a FK RESTRICT. Those six cover nearly every case.
How can I help you explore Laravel packages today?