mpociot/reanimate
Reanimate adds a recycle bin to Laravel Eloquent models. Use the Reanimate trait to delete records and restore them later without adding soft-delete columns to your tables—deleted models are stored separately, keeping your schema clean while deletions stay undoable.
Read the source first — it's small. The whole package is roughly a trait plus a restore loop. Before you design anything on top of it, open src/ and confirm three things: the exact trait name and namespace (Mpociot\Reanimate\Reanimate vs. something else), whether cascade relations are declared in an $reanimate property or discovered reflectively, and whether it wraps native SoftDeletes or persists attribute snapshots. That last point is the one that changes your risk profile — a snapshot mechanism means a second copy of your data with its own retention and erasure obligations. Five minutes of reading saves a week of assumption.
Check the constraints before you composer require. Look at composer.json for illuminate/* and PHP ranges and at the last commit date. This was authored in an earlier Laravel era; on Laravel 10+ you may be installing under --ignore-platform-reqs (don't) or vendoring/forking it (fine — it's tens of lines, not hundreds).
composer require mpociot/reanimate
php artisan vendor:publish --provider="Mpociot\Reanimate\ReanimateServiceProvider"
php artisan route:list | grep -i reanimate # does it register routes at all?
Opt in an explicit allowlist of models. Never put the trait on a shared base model. The capability should exist only where you've consciously decided "delete is reversible here."
class Invoice extends Model
{
use SoftDeletes;
use \Mpociot\Reanimate\Reanimate;
// confirm the property name in source; this is the canonical pattern
protected $reanimate = ['lineItems', 'attachments'];
}
First use case: one guarded admin action, not a feature. Wire it to a single controller method behind your existing auth and policies. Don't expose package routes directly — they have no opinion about authorization, and the package's entire job is surfacing records you deliberately hid.
Route::post('/admin/invoices/{invoice}/restore', [InvoiceController::class, 'restore'])
->middleware(['auth', 'can:restore,invoice'])
->withTrashed(); // route model binding ignores trashed rows otherwise
public function restore(Invoice $invoice)
{
abort_unless($invoice->trashed(), 409);
DB::transaction(fn () => $invoice->reanimate());
return back()->with('status', 'Invoice restored.');
}
That's the whole first slice. Ship it, watch the audit log, then decide whether you need the cascade at all.
Wrap every restore in a transaction. A cascade touches a parent plus N children; a partial cascade leaves you in a state nobody designed for. DB::transaction() is the cheapest insurance in the whole integration.
DB::transaction(fn () => $invoice->reanimate());
Authorize at the boundary, not in the model. Three checks, in this order: is the actor allowed to restore this type (policy), is this record in the actor's tenant (explicit scope, not just a global scope), and is the record actually trashed (409, not a silent success). The package supplies none of these.
$invoice = Invoice::withTrashed()
->where('tenant_id', $tenant->id)
->findOrFail($id);
Hook restored for audit and downstream repair. A restore is a production mutation, not a UI nicety. One listener records actor, model, key, timestamp, request ID — and repairs everything that already treated the delete as final: search index re-entry, cache busting, denormalized counters, outbound webhooks to consumers.
Event::listen('eloquent.restored: *', function ($event, $models) {
$model = $models[0];
AuditLog::record('restored', $model, ['actor_id' => auth()->id()]);
SearchIndex::reindex($model);
Cache::tags([$model->cacheTag()])->flush();
});
Define cascade semantics explicitly, per relationship. "Restoring an order restores its soft-deleted line items" is a product decision, not a package default. Be deliberate about ordering (children first if FKs matter), and decide up front what happens when a child was hard-deleted or independently restored in the interim. Write a test for each of those three cases.
Bulk restore: loop with per-record outcomes, don't batch. All-or-nothing bulk restore sounds tidy until one row collides with a unique constraint and reverts 200 good restores. Return a partial-failure payload the UI can render.
$results = collect($ids)->map(fn ($id) => rescue(
fn () => tap(
Invoice::withTrashed()->findOrFail($id),
fn ($m) => DB::transaction(fn () => $m->reanimate())
),
fn ($e) => ['id' => $id, 'error' => $e->getMessage()],
report: true
));
Mirror the cascade on delete. If restoring a parent brings back children, deleting a parent should soft-delete them too. Asymmetric cascade logic is how "delete means X except when it means Y" creeps into a codebase and never leaves.
Eager-load everything the cascade touches. Relation-by-relation restore loops are N+1 generators by construction. Invoice::withTrashed()->with(['lineItems' => fn ($q) => $q->withTrashed()]) before calling reanimate() keeps it to a handful of queries.
Cover it in CI, one feature test per releasable model. Create → delete → restore → assert deleted_at is null, children are back, and the audit row exists. This is also your regression net for the day you replace the package with in-house code.
->withTrashed() on the route or a resolveRouteBinding override. This is the #1 "the package is broken" moment and it has nothing to do with the package.restore() on a non-trashed model returns true and does nothing. It's idempotent, so abort_unless($model->trashed(), 409) if you want an honest HTTP response instead of a lying 200.slug or email was reused after deletion, the restore throws QueryException. Decide up front: block, suffix, or fail loudly — and surface which one happened.restoring event can return false to abort. Handy for policy hooks; a footgun when an unrelated observer returns false for its own reasons and your restore silently no-ops.updated_at gets bumped on restore. Anything keyed to "last modified" now claims the record changed today, which is wrong for reporting and can reshuffle sort orders.deleted_at and pushes the row out of the purge window unexpectedly.php artisan route:list and then re-check with route:cache on. Verify they sit behind your middleware group and that nothing explodes in production.restore() plus a policy is already enough — and costs you one fewer dependency on your data-recovery path.How can I help you explore Laravel packages today?