Data recovery · Open source
Deleted Records
Lists the records spatie/laravel-deleted-models kept when they were deleted, previews their stored attributes, and restores or permanently deletes them.
What it solves
In a Filament panel a delete is final. Undoing one wrong click means restoring a database backup or retyping the record by hand.
spatie/laravel-deleted-models copies a model's attributes to a deleted_models table when it is deleted, but only gives you Model::restore($key) in code. This plugin adds the screen: a Deleted records list with filters by type, by who deleted it and by date, a preview of every stored attribute with a check for whether a restore can work, and restore or permanent delete, one at a time or in bulk. Out of the box, anyone who can open the panel can see the list and restore or purge records, unless the original model's policy, a policy for the deleted-record model or your own callback says otherwise. Built on spatie/laravel-deleted-models by Spatie. An independent plugin, not made or endorsed by Filament or Spatie.
- One Deleted records list, newest first, with a readable title, the key, the model type, who deleted it and when, and a count badge in the navigation
- Filters by model type, by who deleted it and by date range; search matches the key exactly or the title attributes, never masked values
- Preview of every stored attribute, with password, remember_token, api_token and the two-factor columns masked, and a restore check that flags a taken key or a missing model class
- Restore through the model's own restore(), so its beforeRestoringModel and afterRestoringModel hooks and Spatie's restore events run; the notification links to the record when the panel has a resource for it
- Delete permanently, plus bulk restore and bulk permanent delete; a failed restore keeps its stored copy
- Optional deleted_by_id column, filled with the signed-in user when the copy is written
- Permissions from your callback, a policy for the deleted-record model, or the original model's view, restore and forceDelete policy methods
Install
Requires PHP 8.3+, Filament 4 and 5, and Laravel 12 and 13.
composer require amjadiqbal/filament-deleted-records
Usage
use AmjadIqbal\FilamentDeletedRecords\DeletedRecordsPlugin;
use Spatie\DeletedModels\Models\Concerns\KeepsDeletedModels;
class Invoice extends Model
{
use KeepsDeletedModels;
}
// AdminPanelProvider
$panel->plugin(DeletedRecordsPlugin::make());
Setup
spatie/laravel-deleted-models is installed with the plugin and stores the copies in its deleted_models table, so its migration has to be published and run. The plugin's own migration is optional: it adds a deleted_by_id column, and without it the "Deleted by" column and filter stay hidden. On Laravel 13, Spatie's package needs PHP 8.4. Register the plugin on the panel to add the Deleted records page; ->resource(false) leaves it out and ->resource(MyResource::class) swaps in your own subclass.
- 1 composer require amjadiqbal/filament-deleted-records
- 2 php artisan vendor:publish --tag="deleted-models-migrations"
- 3 Optional, for "Deleted by": php artisan vendor:publish --tag="filament-deleted-records-migrations"
- 4 php artisan migrate
- 5 Add use KeepsDeletedModels; to each model whose deleted records you want to keep.
- 6 Add ->plugin(DeletedRecordsPlugin::make()) to the panel provider.
Which records are kept
The plugin does not keep anything itself. Spatie's KeepsDeletedModels trait listens for the model's deleted event and writes the key, the model type and the attributes to deleted_models. Only models with the trait are kept, and only deletes that go through Eloquent fire that event.
- A query-builder delete such as Invoice::where(...)->delete(), or a database ON DELETE CASCADE, fires no model event, so nothing is kept.
- Only the row's own attributes are stored, including hidden ones such as password hashes. Related rows (line items, pivot rows, files) are not, unless their models use the trait too and are deleted through Eloquent.
- Spatie's table allows one stored copy per model type and key.
- ->models([Invoice::class]) limits the list, the badge and the type filter to those models (class names or morph aliases); empty shows every model.
- Copies are kept until restored or deleted. Spatie's prune_after_days (365 by default) only applies if you schedule Laravel's model:prune for the deleted-record model; the plugin does not prune.
The list and filters
The Deleted records page lists every stored copy, newest first. Each row shows a title, the key, the model type as a badge, who deleted it and how long ago, with the exact time on hover. The title is the first filled value of name, title, label, subject, email, number, reference or slug, else the key; ->titleAttributes([...]) changes that list and ->modelLabels([...]) renames types. The navigation badge counts the copies and is hidden at zero or with ->navigationBadge(false). The page has no create or edit actions.
- Type filter: pick one or more of the model types present in the bin.
- Deleted by filter: the users who appear in the bin, shown by name or by ->userTitleAttribute('email'). Hidden when the deleted_by_id column is missing.
- Date filter: Deleted from and Deleted until, on the day the copy was written.
- Search matches the key exactly, or text in the title attributes (case-insensitive). It never searches the rest of the stored values, so masked attributes cannot be found by searching.
- The page is not scoped to a Filament tenant. In a multi-tenant panel every tenant's deleted records appear together; restrict viewAny or override getEloquentQuery() in your own resource subclass.
The preview
The view action opens a modal headed with the type and title. Its Summary card shows the type, the key, when it was deleted, who deleted it (when tracked) and a restore status. The Stored attributes table lists every stored attribute as text: null as null, booleans as true or false, and arrays or JSON columns as JSON. The modal has a Restore button.
- Ready to restore: the model class exists and no row with that key is in its table.
- A record with this key exists: a row already has the key. Global scopes are ignored for this check, so a soft-deleted row also counts.
- Model class not found: the class, or the morph alias it was stored under, no longer resolves.
- The status is a warning only. Restore is still offered, and a restore that cannot work fails and reports why.
- Masked by default: password, remember_token, api_token, two_factor_secret and two_factor_recovery_codes. ->maskedAttributes(['iban']) adds more; pass false as the second argument to replace the defaults. Masking only hides values on screen; they stay in the database.
Restore
Restore asks for confirmation, then calls the model's own restore($key) when the class uses KeepsDeletedModels, or the stored copy's restore() otherwise. In Spatie's code, that builds a new model, fills it with the stored attributes (key included) and saves it inside a database transaction, fires RestoringDeletedModelEvent and DeletedModelRestoredEvent, and deletes the stored copy. Through restore($key), the model's beforeRestoringModel and afterRestoringModel hooks also run. The success notification has an Open link when the panel has a resource for the model with a view or edit page the user may open.
- Key taken: the save fails, the transaction is rolled back and the stored copy stays. The plugin shows "Could not restore" with the reason when it can tell (key taken or class missing).
- Table changed since the delete: the stored attributes are written as they are. A dropped column or a new required column without a default makes the save fail the same way, and the plugin does not check for this in advance. A new nullable column, or one with a default, gets null or its default.
- Relations are not restored. Each kept record is restored on its own, so restore a parent before children whose foreign keys need it.
Delete permanently
Delete permanently sits in the row's action menu, asks for confirmation and deletes the stored copy from deleted_models. The original row was already deleted, so after this the record cannot be brought back from the plugin.
Bulk actions
Select rows to restore them or delete them permanently together. Both check each record and skip the ones the user may not touch.
- Bulk restore restores the rest one by one and keeps the copy of any that fails. It reports how many were restored, how many could not be restored (for example, because a record with that key already exists) and, separately, how many were skipped because the user may not change them.
- Bulk delete reports how many copies it deleted and, separately, how many were skipped because the user may not delete them.
- Records are processed in selection order, with no ordering between parents and children.
Who can do what
Every check (viewAny, view, restore, forceDelete, restoreAny, forceDeleteAny) runs in this order. Out of the box, with no callback, no policy for the deleted-record model and no matching policy methods on your models, any user who can open the panel can see the list, preview any record and restore or permanently delete it.
- Your callback: ->authorizeUsing(fn (string $ability, ?DeletedModel $record, ?User $user): bool => ...) decides alone when set.
- A policy for the deleted-record model (Spatie's DeletedModel, or your class in deleted-models.model) decides alone when registered, for all six abilities.
- The original model's policy: for view, restore and forceDelete on a single record, a policy method with exactly that name decides, and it receives an unsaved model filled with the stored attributes. If the policy has no such method, the action is allowed.
- Laravel's make:policy stubs return false from view, restore and forceDelete until you edit them, so with a generated policy nobody can preview, restore or purge that model here. These are the same methods Filament uses for soft-deleted records.
- The original model's policy is never asked about the list itself or the bulk buttons: viewAny, restoreAny and forceDeleteAny are allowed unless the callback or a deleted-record policy says no. Titles, keys and who deleted each record are visible to every panel user.
DeletedRecordsPlugin::make()
->authorizeUsing(fn (string $ability, ?DeletedModel $record, ?User $user): bool => $user?->is_admin ?? false)
->models([Invoice::class, Customer::class])
->navigationGroup('System')
->maskedAttributes(['iban']);
Who deleted it
When the deleted_by_id column exists and track_deleted_by is on (the default), the plugin fills the column with the signed-in user each time Spatie writes a copy, wherever the delete happens. The name shown is looked up on config('deleted-records.user_model'), or the default auth guard's user model when that is null.
- A delete with no signed-in user, such as in a queued job, a console command or a scheduled task, leaves it empty and the column shows a dash.
- If the panel signs in through a guard whose users live in a different model from the default guard, set user_model, or the wrong user or a bare #id is shown.
- A user who no longer exists is shown as #id.
- Records deleted before the column was added have no value.
Credits
Deleted Records is built on spatie/laravel-deleted-models by Spatie (MIT), which keeps the deleted records and restores them. This plugin adds the Filament side: the list, the filters, the preview, the actions and the permission checks. It is not affiliated with or endorsed by Spatie.
Hire me
Need this built into your app, or something custom?
I wrote Deleted Records. I can wire it into your Filament panel, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.
Support
Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.
More Filament plugins
All plugins
RankForge Lite
Adds meta, Open Graph and X fields to any Filament resource, scores the content for SEO and readability as you type, and previews the Google result before you publish.
Change Requests
Holds edits to Filament records as change requests that a reviewer checks field by field, then approves, rejects or rolls back.