skrepr/skrepr-soft-deletable-extension-bundle
Add soft-delete support to Symfony and Doctrine entities with this bundle. Mark records as deleted without removing them from the database, restore them when needed, and permanently purge only when appropriate, with extension points for custom behavior.
This is the initial 1.0.0 release, so there is no migration path to worry about — but also no battle-testing yet. Treat 1.0 as "first public API", not "frozen API".
composer.json first. The package name ends in -extension-bundle, which is Symfony bundle convention. Before anything else, confirm composer.json requires illuminate/database / illuminate/support and ships a Laravel service provider. If it's a Symfony bundle with no Laravel bridge, stop here — it won't integrate without an adapter.composer require skrepr/skrepr-soft-deletable-extension-bundle:^1.0. Pin to ^1.0 rather than dev-main; early releases churn.extra.laravel.providers in the package's composer.json. If present, Laravel auto-discovers it. If not, add the provider to bootstrap/providers.php (Laravel 11+) or config/app.php.php artisan vendor:publish --provider="Skrepr\...\ServiceProvider"
php artisan migrate
If your tables already have deleted_at, skip this — check the config for the column name.$post->delete(); // row still in DB, deleted_at set
Post::find($id); // null
Post::withTrashed()->find($id)->restore(); // back
Where to look in the source: src/ for the trait, global scope, and any SoftDeletable interface; tests/ for the canonical usage the authors actually exercise. The tests are the most reliable documentation at 1.0.0.
Trait + interface on the model. Expect the standard shape: a trait that bootstraps a global scope and adds restore() / forceDelete() / trashed() helpers, plus an interface so the package can type-hint. Apply the trait to models individually — don't try to bolt it onto a base model and inherit, since boot traits and global scopes interact badly with inheritance chains.
Three-tier querying. Build your repositories/actions around the three modes rather than sprinkling scope calls:
withTrashed(): admin/trash views, audit logs, referential lookupsonlyTrashed(): restore UIs and purge jobsRoute model binding. Default binding uses the scoped query, so a trashed record 404s automatically — which is usually what you want. For an admin "view deleted item" route, use explicit binding:
Route::get('/admin/posts/{post}', ...)->withTrashed();
Cascade decisions belong to you. Most soft-delete packages do not cascade. If deleting a Post should hide its Comments, do it explicitly in an observer or a domain service — and decide whether restoring the parent restores children (usually it should not, silently).
Purge strategy. forceDelete() is destructive and irreversible. Wrap it in a scheduled command with a retention window (onlyTrashed()->where('deleted_at', '<', now()->subDays(30))->forceDelete()), chunked, and logged. Never expose raw force-delete to a controller action without authorization.
Testing. With RefreshDatabase, assert on the database rather than the model instance:
$this->assertSoftDeleted($post); // Laravel's built-in assertion
$this->assertDatabaseHas('posts', ['id' => $post->id]);
Add a test that raw DB::table() queries do see trashed rows — it documents the scope boundary for your team.
API surface. Decide early whether deleted_at is exposed in resources. Exposing it lets clients render tombstones; hiding it means a deleted record simply vanishes, which is usually cleaner.
The naming is a red flag worth confirming. -extension-bundle suggests Symfony heritage. Verify the provider is Laravel-native before building on it; if it's a thin wrapper, you may be better served by Laravel's built-in SoftDeletes trait, which is already tested and maintained.
Unique constraints break on soft delete. A UNIQUE(email) index still sees the trashed row, so a user can't re-register after deletion. Fix with a partial/composite index (UNIQUE(email, deleted_at)) or by nulling the column on delete. This bites everyone on day one.
Global scopes don't apply to raw queries, DB::table(), or exists() on a manually built join. If your code mixes Eloquent and the query builder, trashed rows leak into counts and reports. Grep for DB::table in any model that gains the trait.
toSql() hides scopes. Debugging "why is my row missing?" — inspect ->toSql() and bindings, or Model::withoutGlobalScopes(). Also watch for ambiguous 'deleted_at' errors once you join two soft-deletable tables; qualify the column.
Mass updates bypass model events. Post::where(...)->update([...]) and ->delete() on a builder skip deleting/deleted events. If you hook observers for auditing, use ->each(fn ($m) => $m->delete()) or chunk-and-delete instances.
Restore can collide. Restoring a record whose unique key has since been reused throws a DB exception, not a friendly error. Handle it in the restore action.
forceDelete and foreign keys. Row removal triggers FK constraints that soft deletes never did. Test purges against seeded related data, not an empty database.
Config publishing. Published config is not updated by later package releases — vendor:publish --force will clobber your edits. Diff before overwriting, and prefer env vars where the config supports them.
Extension points to check at 1.0.0: the column name (is deleted_at configurable per model?), whether a custom deleted-by/timestamp column is supported, and whether the package fires events you can hook. If the scope is hardcoded with no withoutGlobalScope escape hatch documented, plan your own wrapper.
Version risk. For an initial release, read CHANGELOG.md on each bump before upgrading within ^1.0, and keep the trait usage behind your own base trait or interface so a swap back to Laravel's built-in SoftDeletes is a one-file change if the package stalls.
How can I help you explore Laravel packages today?