Key takeaways
- ✓ Generate with
--soft-deletes, or addTrashedFilterplus the restore and force-delete actions (and their bulk versions) to an existing resource. - ✓ The important bit: override
getRecordRouteBindingEloquentQuery()to removeSoftDeletingScope, or edit and view pages 404 for trashed records. - ✓ A Trash tab on the list page is friendlier than a filter for non-technical users.
- ✓ Watch the side effects: unique rules that ignore trashed rows, orphaned children, and a trash that grows forever unless you prune it.
“I deleted the wrong customer” is a support ticket every admin panel eventually gets. Soft deletes turn it from a database-restore emergency into a two-click fix. Laravel handles the data side with the SoftDeletes trait, and Filament v5 has everything for the UI side. The details are what separate a working trash from a confusing one, so this guide covers those too.
Also Read: Laravel: Best Filament Themes
Step 1: Prepare the model
// migration
Schema::table('customers', function (Blueprint $table) {
$table->softDeletes();
});
// model
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model
{
use SoftDeletes;
}Step 2: Resource support
New resources
php artisan make:filament-resource Customer --soft-deletesThe generator adds everything below for you.
Existing resources
Add the filter and actions to the table:
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
public static function table(Table $table): Table
{
return $table
->columns([
// ...
TextColumn::make('deleted_at')->dateTime()->since()
->label('Deleted')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TrashedFilter::make(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
RestoreBulkAction::make(),
ForceDeleteBulkAction::make(),
]),
]);
}The prebuilt actions show themselves only when they make sense: RestoreAction on trashed rows, DeleteAction on live rows.
Then the step people miss:
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return parent::getRecordRouteBindingEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}Without this, clicking a trashed row returns a 404, because route model binding still excludes soft-deleted records.
Finally, add the actions to the edit (or view) page header:
// Pages/EditCustomer.php
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
];
}Step 3: A Trash tab instead of a filter
TrashedFilter (Without / With / Only trashed) works, but it’s hidden in the filter dropdown, and since v4 filters are deferred, so users have to click Apply. For non-technical users, tabs on the list page are much clearer:
// Pages/ListCustomers.php
use App\Models\Customer;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
public function getTabs(): array
{
return [
'active' => Tab::make('Active'),
'trash' => Tab::make('Trash')
->icon('heroicon-m-trash')
->badge(fn () => Customer::onlyTrashed()->count() ?: null)
->badgeColor('gray')
->modifyQueryUsing(fn (Builder $query) => $query->onlyTrashed()),
];
}If you use tabs, drop TrashedFilter so the two can’t disagree. Keep getRecordRouteBindingEloquentQuery(), which you still need.
For a nicer empty state in the trash:
->emptyStateHeading(fn ($livewire) => $livewire->activeTab === 'trash' ? 'Trash is empty' : 'No customers yet')Make trashed records obvious on the edit page
When someone opens a trashed record from the Trash tab, they should see immediately that it’s in the trash, and ideally shouldn’t be able to edit it until it’s restored. Filament v4 and v5 include a callout schema component for exactly this kind of message:
use Filament\Schemas\Components\Callout;
use Filament\Schemas\Components\Section;
public static function form(Schema $schema): Schema
{
return $schema->components([
Callout::make('This customer is in the trash')
->description('Restore it to make changes. Items in the trash are deleted permanently after 30 days.')
->warning()
->visible(fn (?Customer $record) => $record?->trashed())
->columnSpanFull(),
Section::make('Details')
->disabled(fn (?Customer $record) => $record?->trashed())
->schema([
// ... fields
]),
]);
}Disabling the section while the record is trashed stops “edit in the trash, then wonder why nothing happened” confusion. RestoreAction in the page header remains available.
Also Read: Laravel: Filament Blueprint Review
Exporting and reporting on trashed data
Filament’s ExportAction exports the table’s current query, including the active tab and filters. Users can export exactly what’s in the trash for review before a purge, which is useful for GDPR erasure logs. Our CSV import and export guide covers exporters.
Step 4: Permissions
Filament checks these policy methods:
| Action | Policy method |
|---|---|
| Delete / bulk delete | delete() / deleteAny() |
| Restore / bulk restore | restore() / restoreAny() |
| Force delete / bulk force delete | forceDelete() / forceDeleteAny() |
A sensible default: support staff can delete and restore, and only admins can force delete.
public function forceDelete(User $user, Customer $customer): bool
{
return $user->hasRole('admin');
}
public function forceDeleteAny(User $user): bool
{
return $user->hasRole('admin');
}For performance, bulk actions check the *Any method once. If permissions vary per record, call ->authorizeIndividualRecords() on the bulk action to check delete() (or restore()/forceDelete()) for each record. For the full permission set-up, see roles and permissions in Filament.
Step 5: The side effects nobody mentions
Unique columns and trashed rows
A soft-deleted customer still holds its email in the table. Creating a new customer with the same email fails the unique check, and possibly your database unique index too.
Decide which you want:
- Deleted emails stay reserved. Keep the rule. Tell the user to restore the old record instead.
Deleted emails are reusable. Exclude trashed rows from validation:
use Illuminate\Validation\Rules\Unique; TextInput::make('email') ->email() ->unique(ignoreRecord: true, modifyRuleUsing: fn (Unique $rule) => $rule->whereNull('deleted_at')),
For the second option, the database index needs changing as well. MySQL doesn’t support partial indexes, so the common pattern is a generated column or a unique index on (email, deleted_at). PostgreSQL can use CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL.
Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work - Laravel
Children of a soft-deleted parent
Soft-deleting a customer doesn’t soft-delete their invoices, and relation managers on a trashed customer’s page still show the related records. Either cascade in a model event:
protected static function booted(): void
{
static::deleted(function (Customer $customer) {
if (! $customer->isForceDeleting()) {
$customer->invoices()->each->delete();
}
});
static::restored(fn (Customer $customer) => $customer->invoices()->onlyTrashed()->restore());
}…or keep the children and make their tables show the parent’s status clearly.
Relation managers and trashed children
If the related model uses soft deletes, relation managers hide trashed rows by default. To manage them there, add TrashedFilter and the restore actions to the relation manager’s table, and remove the scope from its query:
->modifyQueryUsing(fn (Builder $query) => $query->withoutGlobalScopes([SoftDeletingScope::class]))Global search and selects
Trashed records are excluded from global search and Select::relationship() options, which is usually correct. Nobody should assign an order to a deleted customer.
Auditing
Soft deletes and restores fire deleted and restored model events, so an audit log records who trashed and restored what. Force deletes remove the subject, so make sure your audit log stores enough context (a name, an email) to be useful afterwards.
Step 6: Empty the trash automatically
A trash that’s never emptied becomes a compliance problem, especially for personal data under GDPR. Use Laravel’s pruning:
use Illuminate\Database\Eloquent\MassPrunable;
class Customer extends Model
{
use SoftDeletes, MassPrunable;
public function prunable(): Builder
{
return static::onlyTrashed()->where('deleted_at', '<=', now()->subDays(30));
}
}// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('model:prune')->daily();MassPrunable deletes with a single query and fires no model events. Use Prunable instead if you need observers to run, for example to delete files from S3 or write audit entries. Tell users about the retention period in the UI: “Items in the trash are permanently deleted after 30 days.”
Testing
use Filament\Actions\RestoreAction;
use Filament\Actions\Testing\TestAction;
it('restores a trashed customer', function () {
$customer = Customer::factory()->create();
$customer->delete();
livewire(ListCustomers::class)
->set('activeTab', 'trash')
->assertCanSeeTableRecords([$customer])
->callAction(TestAction::make(RestoreAction::class)->table($customer));
expect($customer->fresh()->trashed())->toBeFalse();
});
it('opens the edit page for a trashed customer', function () {
$customer = tap(Customer::factory()->create())->delete();
livewire(EditCustomer::class, ['record' => $customer->getRouteKey()])->assertOk();
});The second test catches a missing getRecordRouteBindingEloquentQuery() override straight away. More in testing Filament panels with Pest.
Also Read: What Is Jev? TypeSafe AI's 'System One' Model Explained (Pricing, API & Use Cases)
FAQ
How do I add soft deletes to a Filament resource?
Add SoftDeletes to the model. Then add TrashedFilter, RestoreAction, ForceDeleteAction and the bulk versions to the resource, and override getRecordRouteBindingEloquentQuery() to remove SoftDeletingScope. For new resources, use make:filament-resource --soft-deletes.
Why do I get a 404 when opening a trashed record in Filament?
Route model binding still applies the soft-delete scope. Override getRecordRouteBindingEloquentQuery() on the resource to call withoutGlobalScopes([SoftDeletingScope::class]).
How do I show a Trash tab in Filament?
Define getTabs() on the List page with a tab whose modifyQueryUsing() removes the soft-delete scope and calls onlyTrashed().
How do I permanently delete old trashed records?
Use Laravel’s Prunable or MassPrunable trait with a prunable() query that targets trashed records older than your retention period, and schedule model:prune.
Sources and further reading
- Filament: deleting records and soft deletes
- Filament restore action
- Filament force-delete action
- Laravel soft deleting
- Laravel pruning models
Related on The Web Tier: Filament actions and bulk actions
