petecoop/filament-toolbar-filters
A Filament plugin that adds toolbar filters to your admin tables, letting users refine records quickly from a compact toolbar instead of the standard filter form. Simple to install and configure for faster, cleaner table filtering.
Verify the compatibility surface before writing code. 0.1.0 shipped with empty release notes, so the first thing to do is read the vendor source, not the README:
composer require petecoop/filament-toolbar-filters
composer show petecoop/filament-toolbar-filters # installed version + constraints
Then open the source tree — this is where the real documentation lives:
vendor/petecoop/filament-toolbar-filters/composer.json — check the filament/* and illuminate/* constraints against your pinned versions. A missing or wildcard constraint is a silent resolution risk.src/ — look for the trait, macro, or table-configuration method that registers toolbar filters (names to look for: toolbarFilters(), ToolbarFilter, a HasToolbarFilters trait, or a Filter subclass). This method name is what you'll be typing every day, so confirm it from source rather than guessing.resources/views/ — if the package ships Blade views, you need to know whether it uses precompiled CSS or arbitrary Tailwind classes (see Gotchas).src/Providers/*.php — what gets registered, and whether anything is publishable (php artisan vendor:publish --provider=...).Pin immediately. With a 0.x release, a minor bump may break you:
"petecoop/filament-toolbar-filters": "0.1.0"
First use case: take one existing filter out of the modal. The package is a rendering change, not a filtering change — your existing Filament filter definitions should be reusable as-is. Pick a high-traffic resource with one or two SelectFilters (status, type, "mine only") and try:
public function table(Table $table): Table
{
return $table
->filters([
SelectFilter::make('status')
->options(OrderStatus::class),
]);
// then add the package's registration call here,
// passing the same filter objects (per package source)
}
Validate three things in that first session: the dropdown renders above the table, selecting a value actually narrows getFilteredTableQuery(), and clearing the filter restores the full list. If all three work, you've proven the integration; if the query doesn't change, the package is rendering UI but not wiring state, and you should stop there.
Keep one source of truth for filter definitions. Don't maintain a "toolbar filters" list and a separate ->filters() list. Extract them into a method on the resource (or a dedicated Filters/ class) and feed both the toolbar and the native panel:
protected function orderFilters(): array
{
return [
SelectFilter::make('status')->options(OrderStatus::class),
SelectFilter::make('channel')->options(Channel::class)->multiple(),
Filter::make('overdue')
->query(fn (Builder $q) => $q->where('due_at', '<', now())),
];
}
This is what makes the package cheap to remove later — the filter semantics stay in your codebase, only the presentation moves.
Map filter shape to toolbar widget deliberately. Compact toolbars punish wide controls:
SelectFilter with ->options() and no ->multiple() → dropdown, ideal.SelectFilter with ->multiple() → multi-select dropdown; check the label renders as "2 selected" rather than a comma-joined wall of text.TernaryFilter → a toggle/tri-state button. State here is ['value' => true|false|null], not a scalar, so any ->query() must unwrap it ($data['value'] ?? null).Filter::make('x')->query(...) with no ->form() → a plain toggle button. Good for "only mine", "overdue".Filter with a ->form([...]) of several fields → a dropdown holding a form. These are the ones that bloat a toolbar; keep them to two fields max or leave them in the modal panel.Write queries that always return the builder. The most common runtime error in Filament filters is returning null from a ->query() closure:
Filter::make('active')
->query(function (Builder $query, array $data): Builder {
if (filled($data['value'] ?? null)) {
$query->where('is_active', (bool) $data['value']);
}
return $query; // required
})
Read state through Filament's API, not through the DOM. Toolbar filters are still table filters, so anything you build around them (a summary line, a CSV export, an "active filters" chip row) should read $this->getTableFilters() / $this->tableFilters on the Livewire component rather than mirroring state in your own property. Mirroring is how you end up with a toolbar and an export that disagree.
Export and bulk actions must use the filtered query. Filament's built-in header actions operate on the filtered query, but custom ones often don't. If you add a "export" action, be explicit:
->headerActions([
ExportAction::make()->query(fn () => $this->getFilteredTableQuery()),
])
Toolbar filters make filtering feel instant and obvious, which makes it much easier for a user to assume the export respects them. It usually does — verify it.
Keep the native filter modal available. The safest rollout for a 0.1.0 dependency is a per-resource choice: toolbar filters on the two high-traffic tables, ->filtersLayout(FiltersLayout::Modal) elsewhere. That confines any upstream breakage to a small blast radius and gives you a one-line rollback per resource.
Test through the table, not the view. You don't need to assert on the package's markup. Test the filtering contract with Filament's Livewire helpers:
livewire(ListOrders::class)
->filterTable('status', OrderStatus::Active->value)
->assertCanSeeTableRecords($activeOrders)
->assertCanNotSeeTableRecords($inactiveOrders);
If those tests pass with the toolbar in place and with it removed, the package is genuinely a presentation layer in your app — that's the property worth asserting.
Pre-1.0, undocumented, zero stars. Treat the public method names as unstable. Record the exact version in your dependency register, disable automated dependency-bot merges for this package, and re-read the source diff on every bump — the changelog will not tell you what changed. Exercise the removal path (delete the registration call, confirm the table still works with native filters) during the pilot, not on the day you need it.
Compiled CSS is the most likely visual failure. Filament ships precompiled Tailwind. If the package's views use arbitrary utility classes (w-[180px], bg-primary-50/50) that aren't in Filament's build, the toolbar will render unstyled or misaligned. Symptom: correct behaviour, broken layout, no console errors. Fixes are either a published view you restyle with classes you control, or adding the vendor view path to your own Tailwind content array and rebuilding. Confirm which before you promise a UI.
Filters with no form render as empty popovers. Filter::make('overdue')->query(...) has no form schema; if the toolbar implementation assumes every filter has a form to render, you get a button or dropdown that opens nothing. Prefer ->toggle()-style or a single-field ->form() for boolean filters, and click every toolbar item in the pilot — don't just read the code.
The ternary null state is not false. TernaryFilter state is ['value' => null] when untouched. filled() and (bool) coercions behave differently here; a naive if ($data['value']) silently hides records it should not. Always branch on !== null for tri-state filters.
Session persistence makes "stuck" filters look like bugs. If the resource uses ->persistFiltersInSession(), a user who filtered last week still sees a filtered list and reports missing records. Decide per resource: persistence is nice for power users, terrible for support tickets. Test by filtering, navigating away, and coming back in a fresh browser session.
Clearing filters has two meanings. resetTableFilters() clears all filters; resetTableFiltersForm() behaves differently, and individual toolbar controls may not emit the same events as the modal. Verify that the "clear" affordance visibly resets every control's own display, not just the query — a dropdown still showing "Active" over an unfiltered list reads as a bug.
Live-updating every control re-runs the query on each interaction. A multi-select that fires per checkbox toggle can mean three full table renders for one user intent. If the package exposes a live()/deferred toggle, use it; if not, prefer single-value selects on large tables and check DB::listen() to see how many queries a filter click costs.
Multiple tables on one page need distinct state keys. In dashboards or relation managers with more than one table, filter names must be unique per table (->filters([...], ); name collisions across tables are a classic source of "filtering table A changed table B". Scope by table via getTableFilters() on the right component, and give the toolbar a key() if the implementation doesn't.
Debugging: fall back to the modal, then diff. When a toolbar filter misbehaves, the fastest triage is to render the same filter definitions with Filament's native panel. If the native version filters correctly and the toolbar version doesn't, the bug is in state wiring or the registration call — not your ->query(). ->filtersLayout(FiltersLayout::AboveContent) on the same resource gives you a working control group to compare against.
Extension points to prefer. Published Blade views (restyle, don't fork), a subclassed Filter for shared behaviour ("only mine" = ->query(fn ($q) => $q->where('user_id', auth()->id())) defined once), and a thin wrapper method on your own base resource class so the package's registration call appears in exactly one place across the app. That wrapper is what makes a future 1.0 migration or a rip-out a one-file change instead of a grep across forty resources.
How can I help you explore Laravel packages today?