Exclude deleted records from recent history - #39
Conversation
Recent entries store only a URL, so the menu and global search kept listing records that had since been deleted — clicking one lands on Filament's "Record not found" page. Store a nullable polymorphic `recordable` reference when recording an entry (the recorder already has the record), and add an `existing()` scope that hides entries whose record no longer resolves, including soft-deleted ones. Both display surfaces apply the scope. Entries with no record reference are always kept, so existing rows keep displaying.
awcodes
commented
Jul 6, 2026
Can you target this to the 2.x branch? I think it should have Filament v4 support as well. I'll merge it to 3.x once everything is green. |
awcodes
commented
Jul 6, 2026
Also, is there a way to allow the end user to allow for soft deletes if they choose to include them? |
awcodes
left a comment
There was a problem hiding this comment.
Thanks for this — landing on "Record not found" from the menu is a genuine papercut and storing the record the recorder already has is the right fix. Merges cleanly on 3.x and the suite is green. Two things need addressing before this can go in, though, and they change the release it belongs in.
1. Existing installs break on composer update until they migrate
hasMigrations() in laravel-package-tools only registers migrations for publishing — runsMigrations defaults to false and nothing here calls ->runsMigrations():
// vendor/spatie/laravel-package-tools/src/Concerns/Package/HasMigrations.phppublic bool $runsMigrations = false;So recordable_type / recordable_id don't exist until each user publishes and runs the new migration. In the meantime add() writes both columns and existing() filters on them, which means viewing any record and rendering the menu both throw SQL errors. The README note is accurate, but it's an instruction people will skip, and the failure mode is a broken panel rather than a degraded one.
Two ways out:
- Guard the write and the scope on
Schema::hasColumn('recent_entries', 'recordable_type'), so an un-migrated install behaves exactly as it does today and picks up filtering once it migrates. That keeps this backward compatible in practice, not just at the API level. - Or leave it required and ship it in the next major with the migration as a documented upgrade step.
Happy either way, but as written the "SemVer-safe" note in the description holds for the PHP API and not for users.
2. A stale morph type is a fatal error
orWhereHasMorph('recordable', '*') makes Laravel instantiate every distinct recordable_type found in the table:
// QueriesRelationships::hasMorph()$types = $this->model->newModelQuery()->distinct()->pluck($relation->getMorphType())...
// ...later:$query->where(..., (new$type)->getMorphClass())Entries are never cleaned up, so as soon as a model is renamed or removed from the app the old rows still hold the old class string:
RecentEntry::create([
'user_id' => $user->id,
'url' => 'https://example.test/gone',
'icon' => '',
'title' => 'Gone',
'recordable_type' => 'App\Models\ThisClassWasDeleted',
'recordable_id' => 1,
]);
RecentEntry::existing()->pluck('url');Error: Class "App\Models\ThisClassWasDeleted" not found
at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php:1035
← src/Models/RecentEntry.php:63 (scopeExisting)
That takes down both the menu and global search for the affected user, permanently, until the rows are deleted by hand. Since these entries accumulate forever it's a when-not-if over an app's lifetime. scopeExisting() should resolve the stored types itself and skip any that no longer resolve (Relation::getMorphedModel($type) ?? $type, then class_exists), passing the surviving list to orWhereHasMorph instead of '*'. A test for the stale-type case would be good to have.
Smaller notes
- The
'*'wildcard also runs an unscopedSELECT DISTINCT recordable_type FROM recent_entries— whole table, every user — on each menu render and global search. Indexed bynullableMorphs, so survivable, but it's per-request and resolving the types explicitly (above) lets you cache or narrow it. nullableMorphsrespectsSchema::defaultMorphKeyType(), so UUID apps are fine if they've set the global default; apps with mixed key types aren't. It's a published migration so users can edit it — worth a line in the README.tests/database/migrations/create_recent_entries_table.phpaddsnullableMorphsto the create migration, so the actual published add-column migration is never exercised by the suite. Running the real stub would also catch the un-migrated case above.- Adding a fourth parameter to
Recently::add()breaks any subclass that overrides it, since PHP requires a compatible signature. Unlikely to bite anyone given the facade resolves the concrete class, but flagging it.
If #40 also lands
The two interact: #40 prunes to max_items counting entries whose record is gone, while this PR hides those from display. A user who deletes a lot of records ends up with a near-empty menu while N rows sit in the table. Pruning non-existing entries first would resolve it.
Problem
A
RecentEntrystores only a URL, so the menu and the global-search "Recently" group keep listing records that have since been deleted. Clicking one lands the user on Filament's "Record not found" page. Any app using the plugin with deletable (including soft-deletable) records hits this.Change
$recordand discarded it — it's now stored as a nullable polymorphicrecordablereference on the entry.add()gains an optional?Model $record, plus a small backward-compatible migration.existing()scope onRecentEntrylimits results to entries whose record still resolves — aMorphToreturnsnullfor a soft-deleted or missing record. Both display surfaces apply it: the menu (RecentlyMenu::getRecords()) and global search (RecentEntryResource::getGlobalSearchEloquentQuery()).Notes
vendor:publish --tag="recently-migrations"+migrate); README updated with an upgrade note and a "Deleted Records" section.tests/src/DeletedRecordsTest.php): a soft-deleted record's entry is hidden from both surfaces while live and reference-less entries remain.composer testis green.