You add the SoftDeletes trait, drop a TrashedFilter into the table, switch the filter to "With trashed records" — and then you click into a deleted row and Filament hands you a 404. Almost every guide that turns up for filament soft deletes is written against Filament v3 or v4, so the imports, the table method names and the query override are all subtly wrong for v5.
This is the retrofit path: an existing Filament v5 resource, a model that has never been soft-deletable, and no scaffold to lean on. If you are starting fresh, stop reading and run php artisan make:filament-resource Customer --soft-deletes instead — it generates everything below. Everything here is verified against Filament v5.7.6 on Laravel 13.
Add the deleted_at column and the SoftDeletes trait#
Start with the Laravel groundwork, because Filament does nothing until the model is actually soft-deletable. Add a nullable deleted_at timestamp with a migration, then pull the trait into the model.
php artisan make:migration add_deleted_at_to_customers_table --table=customers
Schema::table('customers', function (Blueprint $table): void {
// softDeletes() adds a nullable deleted_at timestamp column
$table->softDeletes();
});
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model
{
use SoftDeletes;
}
The trait registers a global SoftDeletingScope on the model, which appends where deleted_at is null to every query. That scope is the thing you spend the rest of this article negotiating with. The Laravel soft-deleting docs cover the model side in full.
Add the TrashedFilter to the table#
Filament ships TrashedFilter, a prebuilt ternary filter that flips the query between without-trashed, with-trashed and only-trashed. In Filament v5 the table lives in its own class — app/Filament/Resources/Customers/Tables/CustomersTable.php — so that is where the filter goes.
namespace App\Filament\Resources\Customers\Tables;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
class CustomersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('email')->searchable(),
TextColumn::make('deleted_at')
->dateTime()
->label('Deleted')
// Only useful once the filter is showing trashed rows
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TrashedFilter::make(),
])
->recordActions([
EditAction::make(),
]);
}
}
The filter works on its own — it calls withTrashed() and onlyTrashed(), which strip the scope from the table query directly. What trips people up is the default state: blank means without trashed records, so until you open the filter dropdown and pick "With trashed records" or "Only trashed records", the table looks exactly as it did before. If a permanently visible control suits your workflow better, status tabs with counts on the list page can do the same job without a dropdown, and the same query-callback pattern powers custom table filters.
Remove the soft-deleting scope from the record route binding query#
Now the 404. Filament resolves {record} in /admin/customers/1/edit through route-model binding, and that lookup runs through the model's global scopes — including the soft-deleting scope — so a trashed record simply is not found. Override getRecordRouteBindingEloquentQuery() on the resource class and drop just that one scope.
namespace App\Filament\Resources\Customers;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class CustomerResource extends Resource
{
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return parent::getRecordRouteBindingEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}
Filament v3 and v4 tutorials tell you to override getEloquentQuery() with withoutGlobalScopes() instead. That works, but it is a much broader change: getEloquentQuery() is the root of every query the resource makes, so trashed rows start appearing in global search results, in relation managers and in any custom query you build off the resource. getRecordRouteBindingEloquentQuery() delegates to getEloquentQuery() under the hood, which means overriding the narrower method fixes route binding and leaves everything else scoped. If you are still working through v4-era code, the Filament v5 upgrade guide covers the rest of the renames.
Add the restore and force-delete record actions#
Row-level actions go in recordActions() in v5, and all three delete-related actions are imported from Filament\Actions — not Filament\Tables\Actions, which is the v4 location and the single most common copy-paste failure on this topic.
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
->recordActions([
EditAction::make(),
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
])
You do not need ->visible() calls here. Filament decides visibility from the record's trashed state — restore and force delete are hidden on live records, delete is hidden on trashed ones — which is why the docs list all three unconditionally. Reach for ->visible() only when you want a stricter rule than "is this row trashed", such as hiding force delete outside an admin role.
Group the bulk actions in the toolbar#
Bulk actions moved to toolbarActions() in v5 (bulkActions() is the v4 name), and each row action has a *BulkAction counterpart. Wrap them in a BulkActionGroup so the toolbar stays a single dropdown rather than three loose buttons.
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
RestoreBulkAction::make(),
// Load 250 rows at a time instead of the whole selection
ForceDeleteBulkAction::make()->chunkSelectedRecords(250),
]),
])
Bulk actions hydrate every selected model into memory so that model events and per-record policy checks still fire. On large selections that is a memory problem, and chunkSelectedRecords() is the fix. If you need the per-record forceDelete policy consulted rather than just forceDeleteAny, add ->authorizeIndividualRecords(); records that fail the check are skipped instead of throwing.
Add the header actions to the Edit page#
The Edit page has its own action set, and it needs the same three actions — otherwise a user who reaches a trashed record via the fixed route binding lands on a form with no way to restore it.
namespace App\Filament\Resources\Customers\Pages;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Resources\Pages\EditRecord;
class EditCustomer extends EditRecord
{
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
RestoreAction::make(),
ForceDeleteAction::make(),
];
}
}
Write the policy methods for restore and force delete#
Filament reads model policies for every action, and soft deletes add four methods that a policy generated before the retrofit will not have. Miss them and the buttons silently never render, which is a slow bug to chase because nothing errors.
namespace App\Policies;
use App\Models\Customer;
use App\Models\User;
class CustomerPolicy
{
public function delete(User $user, Customer $customer): bool
{
return $user->can('customers.delete');
}
public function deleteAny(User $user): bool
{
return $user->can('customers.delete');
}
public function restore(User $user, Customer $customer): bool
{
return $user->can('customers.restore');
}
public function restoreAny(User $user): bool
{
return $user->can('customers.restore');
}
/** Permanent removal — keep this tighter than delete. */
public function forceDelete(User $user, Customer $customer): bool
{
return $user->hasRole('admin');
}
public function forceDeleteAny(User $user): bool
{
return $user->hasRole('admin');
}
}
The *Any variants exist because bulk actions cannot afford to instantiate every selected record and run the singular check. Filament calls restoreAny() and forceDeleteAny() for the bulk buttons and restore() and forceDelete() for the row and header buttons, so both halves need to agree — a policy that permits restoreAny but denies restore gives you a working bulk restore and a missing row button. For the wider picture, including panel gating and query scoping, see Filament v5 authorization with policies, roles and permissions.
Keep trashed records out of global search and relation managers#
Because you only widened the route-binding query, getEloquentQuery() is still scoped and trashed records stay out of global search for free. This is the payoff for the narrower override, and it is worth verifying rather than assuming — search for a customer you have just deleted and confirm nothing comes back.
If you inherited a resource that already overrides getEloquentQuery() with withoutGlobalScopes(), scope global search back explicitly instead of unpicking the whole resource:
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()->withoutTrashed();
}
Relation managers behave the same way: they build on the relationship query, so trashed children stay hidden unless you ask for them. Occasionally you do want them — an order that still needs to show its deleted line items, for example — and then withTrashed() on the relationship query is the deliberate opt-in rather than a side effect of a resource-wide override.
Scope unique validation rules around trashed rows#
A soft-deleted row is still a row, so a unique rule on email will reject a new customer with the same address as a deleted one. Users read that as a bug. Pass modifyRuleUsing and call withoutTrashed() on the rule.
use Filament\Forms\Components\TextInput;
use Illuminate\Validation\Rules\Unique;
TextInput::make('email')
->email()
->required()
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule): Unique => $rule->withoutTrashed(),
)
Fix the database while you are here. A plain unique index on email will throw a constraint violation on insert no matter what the validation layer decides. Either move to a composite unique index on (email, deleted_at) or, on Postgres, a partial unique index with where deleted_at is null.
Test the delete, restore and force-delete flows with Pest#
Filament pages are Livewire components, so the whole retrofit is testable without a browser. Use TestAction to target a table action against a specific record, and prefer action class names over string names so a rename fails the test rather than silently skipping it.
use App\Filament\Resources\Customers\Pages\ListCustomers;
use App\Models\Customer;
use App\Models\User;
use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\Testing\TestAction;
use function Pest\Livewire\livewire;
beforeEach(function (): void {
$this->actingAs(User::factory()->admin()->create());
});
it('soft deletes a customer from the table', function () {
$customer = Customer::factory()->create();
livewire(ListCustomers::class)
->callAction(TestAction::make(DeleteAction::class)->table($customer));
// refresh() ignores global scopes, so it still finds the trashed row
expect($customer->refresh()->trashed())->toBeTrue();
});
it('hides trashed customers until the filter asks for them', function () {
$live = Customer::factory()->create();
$trashed = Customer::factory()->trashed()->create();
livewire(ListCustomers::class)
->assertCanSeeTableRecords([$live])
->assertCanNotSeeTableRecords([$trashed])
// true is the "with trashed records" branch of the ternary filter
->filterTable('trashed', true)
->assertCanSeeTableRecords([$live, $trashed]);
});
it('restores and then force deletes a trashed customer', function () {
$customer = Customer::factory()->trashed()->create();
livewire(ListCustomers::class)
->filterTable('trashed', true)
->callAction(TestAction::make(RestoreAction::class)->table($customer));
expect($customer->refresh()->trashed())->toBeFalse();
$customer->delete();
livewire(ListCustomers::class)
->filterTable('trashed', true)
->callAction(TestAction::make(ForceDeleteAction::class)->table($customer));
expect(Customer::withTrashed()->find($customer->getKey()))->toBeNull();
});
it('hides force delete from a user without the admin role', function () {
$this->actingAs(User::factory()->create());
$customer = Customer::factory()->trashed()->create();
livewire(ListCustomers::class)
->filterTable('trashed', true)
->assertActionHidden(TestAction::make(ForceDeleteAction::class)->table($customer));
});
The trashed() factory state comes from Laravel's SoftDeletes support in factories, so you get it for nothing. For bulk actions, call selectTableRecords() first and then TestAction::make(RestoreBulkAction::class)->table()->bulk(). More on the assertion vocabulary in testing Filament v5 resources with Pest.
Sidestep the gotchas that outlast the retrofit#
Soft-deleting a parent does not soft-delete its children. Eloquent has no cascade for this, so a deleted customer leaves live orders pointing at a row your table no longer shows. Handle it explicitly in a deleting model event or an observer for the relationships that matter, and resist the urge to build a generic cascade — the correct behaviour differs per relationship, and a blanket rule is how you end up restoring a parent and getting half its children back.
Two more worth knowing. deleted_at is nullable and unindexed by default, and once a table carries a meaningful proportion of trashed rows, every scoped query pays for it — add an index if that table is hot. And soft deletes are not a retention policy: rows accumulate forever unless something removes them. Pair the recoverable window with Laravel's Prunable trait to auto-delete stale records so trashed rows leave permanently after 30 or 90 days, rather than living in the table for the lifetime of the app.
FAQ#
How do I enable soft deletes on a Filament resource?
Add a deleted_at column to the table and the SoftDeletes trait to the model, then update the resource: add TrashedFilter::make() to filters(), add DeleteAction, RestoreAction and ForceDeleteAction to recordActions(), and override getRecordRouteBindingEloquentQuery() to drop SoftDeletingScope. On a brand new resource, php artisan make:filament-resource Customer --soft-deletes generates all of it for you.
Why don't soft deleted records show up in my Filament table?
Because TrashedFilter defaults to the blank state, which means "without trashed records" — the same behaviour you had before adding it. Open the filter dropdown and choose "With trashed records" or "Only trashed records" and the rows appear. If they still do not, confirm the model actually uses the SoftDeletes trait and that the deleted_at migration has run.
Why do I get a 404 when opening a trashed record in Filament?
Route-model binding resolves {record} through the model's global scopes, and the soft-deleting scope excludes the row, so Filament cannot find it. Override getRecordRouteBindingEloquentQuery() on the resource and call withoutGlobalScopes([SoftDeletingScope::class]). Filament v3 and v4 guides suggest overriding getEloquentQuery() instead, which also works but widens every query the resource makes.
What is the difference between delete, force delete and restore in Filament?
DeleteAction runs Eloquent's soft delete, which sets deleted_at and hides the row while keeping the data. RestoreAction clears deleted_at and brings the record back. ForceDeleteAction issues a real DELETE statement and the row is gone permanently, so it is the one to keep behind a stricter policy.
Which policy methods does Filament need for restore and force delete?
Four: restore and forceDelete for the single-record row and header actions, plus restoreAny and forceDeleteAny for the bulk actions. Filament uses the *Any variants for bulk operations because loading every selected record to run the singular check is not performant. If any of the four are missing from the policy, the matching buttons never render.
How do I add a trashed filter to an existing Filament resource?
Import Filament\Tables\Filters\TrashedFilter and add TrashedFilter::make() to the filters() array of your table class — in Filament v5 that is usually app/Filament/Resources/{Model}s/Tables/{Model}sTable.php. The filter handles the query itself with withTrashed() and onlyTrashed(), so no resource-level query change is needed just to make it work. You only need the getRecordRouteBindingEloquentQuery() override once you want to open those trashed records.