nben/filament-record-nav
Adds zero-config next/previous record navigation buttons to Filament 4/5 admin panels. Drop two actions into getHeaderActions()—no trait required—with configurable ordering, page-type targets, smart boundary disabling, and one query per render. Works on ViewRecord and EditRecord pages.
Install and publish config (one command each):
composer require nben/filament-record-nav
php artisan vendor:publish --tag=filament-record-nav-config
If you skip the publish step the package falls back to order_column => 'id', which is fine for most resources and is the fastest path to a working demo.
Where to look first. Read config/filament-record-nav.php — it is three keys and it governs every page in your panel. Then skim the four files that actually matter: src/Actions/Concerns/ResolvesAdjacentRecord.php (the query + cache + URL logic), the two thin action classes, and src/Concerns/WithRecordNavigation.php (the override surface). Everything else is enums and a service provider. There is no lifecycle magic, no configureAction(), no bootUsing() — which is why it will not fight your existing Filament setup.
Your first use case. Pick one ViewRecord page, add the two actions to the header, and check the boundaries:
use Nben\FilamentRecordNav\Actions\NextRecordAction;
use Nben\FilamentRecordNav\Actions\PreviousRecordAction;
class ViewPost extends ViewRecord
{
protected static string $resource = PostResource::class;
protected function getHeaderActions(): array
{
return [
PreviousRecordAction::make(),
NextRecordAction::make(),
Actions\EditAction::make(), // your existing actions still work
];
}
}
Verify three things before rolling it out: the buttons navigate, they go gray on the first and last record, and the ordering matches the order the records appear in your ListRecords table. That third check is where most surprises live.
Keep view as the default and reserve edit for edit-heavy workflows. Navigating view → view is intuitive; navigating edit → edit is where you get the most value if your team lives in a data-entry loop. Mixing is legitimate and reads well:
PreviousRecordAction::make()->navigateTo(NavigationPage::View),
NextRecordAction::make()->navigateTo(NavigationPage::Edit),
Match the config to the resource's table sort. The single most important integration decision is which column defines "adjacent". Default to id when records are created in the order users think about them. Switch to a dedicated sort_order column when editors reorder things manually, and pair it with the resource's defaultSort() so the table and the buttons agree.
Use custom routes for workflow pages, not just view/edit. If a resource has a review or verification step, register it in getPages() and point both actions at it — the whole approval queue becomes a keyboard-driven review flow:
public static function getPages(): array
{
return [
// ...
'verified-view' => ViewVerifiedUser::route('/{record}/verified'),
];
}
PreviousRecordAction::make()->navigateTo(NavigationPage::custom('verified-view')),
NextRecordAction::make()->navigateTo(NavigationPage::custom('verified-view')),
Reach for the trait only when the config is insufficient. The trait is per-page-class, so the right move is a shared abstract page per resource (or one base class if all resources share a scope) rather than copy-pasting overrides. Tenant scoping is the canonical case:
use Nben\FilamentRecordNav\Concerns\WithRecordNavigation;
use Illuminate\Database\Eloquent\Model;
class ViewInvoice extends ViewRecord
{
use WithRecordNavigation;
public function getPreviousRecord(): ?Model
{
return $this->getRecord()->newQuery()
->where('tenant_id', filament()->getTenant()?->getKey())
->where('id', '<', $this->getRecord()->getKey())
->orderByDesc('id')
->first();
}
public function getNextRecord(): ?Model
{
return $this->getRecord()->newQuery()
->where('tenant_id', filament()->getTenant()?->getKey())
->where('id', '>', $this->getRecord()->getKey())
->orderBy('id')
->first();
}
}
Make the buttons feel native. Because the classes extend Filament's Action, styling is free — match them to your other header actions instead of leaving the outlined-primary default if the rest of your panel uses ghost buttons:
PreviousRecordAction::make()
->label('← Previous')
->color('gray')
->size(Size::Small)
->tooltip('Previous record')
->keyBindings(['mod+left']),
NextRecordAction::make()
->label('Next →')
->color('gray')
->size(Size::Small)
->tooltip('Next record')
->keyBindings(['mod+right']),
Keybindings are what turn this from a nice-to-have into a daily driver — mod+left / mod+right gives a reviewer a two-key workflow through a whole queue.
Declare the contract when you override. Adding implements HasRecordNavigation is cosmetic at runtime (the package uses method_exists()), but it buys you IDE completion and PHPStan coverage on getPreviousRecord() signature drift — cheap insurance on a package you're going to pin.
Ties silently swallow records. Any non-unique order_column produces dead ends: two rows with the identical created_at second will never find each other, and seeded/factory data frequently lands on the same timestamp. Symptoms look like randomly gray buttons in the middle of a list. Use id or a genuinely unique sequenced column, and add a DB index on whatever you choose.
Two queries, not one, per render. The internal cache is per-action: it collapses the color() / disabled() / url() closure calls for one action into one query, but a page with both actions fires two queries, and the cache does not survive a re-render. On an EditRecord page this matters — every form field blur triggers a Livewire re-render, which re-evaluates the header action closures. If you have heavy overrides in getNextRecord() (joins, whereHas), guard them with Cache::remember keyed on the record, or accept the cost on view pages only.
Navigating away from a dirty EditRecord form loses edits. The actions do not confirm and do not check $this->form->isDirty(). On edit pages either add ->requiresConfirmation() (clunky but safe) or wrap with your own guard by overriding the URL/disabled logic in the page trait. Do not ship Edit → Edit navigation to a team that types long forms without addressing this.
Previous/next are value-relative, not list-relative. With created_at and the default previous_direction => 'desc', "previous" is the older record. If your table's defaultSort() is created_at desc (newest first), the semantics invert visually and users will complain the arrows are backwards. Either accept it and label them "Older / Newer", or swap the labels relative to the icon direction.
Your table filters and search do not apply. Navigation walks the raw model query, so a user filtered to "draft" will still be taken to the published record next in line. That is usually what you want for "browse everything", but it means the buttons are not a substitute for a filtered list. If the surrounding page implies a filter, replicate it in the trait overrides.
Authorization is not evaluated for the destination record. The package only produces a URL via Resource::getUrl(). Make sure the target page type actually enforces a policy (Filament's resource pages do, but confirm for your custom route pages) so navigation cannot become an access-control bypass. This is the one thing to test explicitly if your resources use scopeQuery-style tenant filtering.
Custom route names must match getPages() keys byte-for-byte. Hyphens, casing, everything. NavigationPage::custom('verified-view') against 'verifiedView' => ... produces a route-not-found exception only when a user clicks, i.e. after deployment.
Never override disabled() or url() on the action yourself. The gray-out and redirect behaviour are these closures; replacing them silently breaks the feature. Style through label(), color(), icon(), size(), tooltip(), keyBindings() only.
Config is cached. After editing order_column, run php artisan config:clear (or config:cache) — a stale cached config is the most common "it works locally, not in staging" report for this package.
Do not attach these to ListRecords or CreateRecord. They resolve the current record from the Livewire component; with no record bound, the closures fail or render permanently disabled. They belong on record-bound pages only.
Debugging a stuck button: add \Log::info($this->getNextRecord()?->getKey()) inside your override, or temporarily dd() in the page's getNextRecord(). If the override never logs, the trait is missing from the class or the method signature drifted from public function getNextRecord(): ?Model (a missing ?Model return type or a missing Model import both break the method_exists() resolution path).
Extension points are deliberately narrow. There is no per-action ->orderBy() and no way to give previous and next different columns. If you need that, override both methods in the page trait — that is the intended escape hatch, and the package is small enough (four files) that forking ResolvesAdjacentRecord is a reasonable last resort if you need ordering that varies at runtime.
How can I help you explore Laravel packages today?