zvizvi/filament-column-filters
Excel-style column header filters for Filament tables: add search, date range, select, and numeric range popups to any column, backed by real Filament filters with sync support and RTL.
Install and register. The package ships as a Filament plugin, so it is opt-in per panel rather than global:
composer require zvizvi/filament-column-filters
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(\Zvizvi\FilamentColumnFilters\FilamentColumnFiltersPlugin::make());
}
Only register it on panels whose tables actually need it. Registering it everywhere adds a filter icon to every column header in the app, which is noise on simple CRUD screens.
Where to look first. The README is short and the public surface is deliberately small — the whole value proposition is three things: plugin registration, a per-column filter declaration, and syncWith(). Before you commit to it on a client project, read:
vendor/zvizvi/filament-column-filters/src/ — the plugin class and the four filter-type classes (Search, Date, Select, Range). That is the real API; the README excerpt is truncated and the class names in this assessment are illustrative.CHANGELOG.md — the only other maturity artefact. Check which Filament majors are actually supported, not just what composer.json allows.composer.lock after install — confirm the resolved Filament version constraint, since a header-slot integration is tightly coupled to Filament's table internals.First use case. The cheapest win is a Search filter on a name/reference column that people constantly scan for, or a Date range on created_at on an operations or finance table. Take one table, add one filter type to one column, click the header icon, confirm the query changes and a standard Filament filter indicator appears with a working remove button. If that works, the plumbing is sound and the rest is repetition.
Declare the filter on the column, next to the column. Keep the declaration in the table schema so it reads like the rest of the table definition, and choose the type per column rather than standardising on one:
// Illustrative shape — verify exact method names against the vendor source.
TextColumn::make('reference')
->searchable()
->columnFilter(ColumnFilter::make('search')),
TextColumn::make('created_at')
->dateTime()
->columnFilter(ColumnFilter::make('date')), // presets + custom from/until
TextColumn::make('status')
->badge()
->columnFilter(ColumnFilter::make('select')->multiple()),
TextColumn::make('total')
->money('EUR')
->columnFilter(ColumnFilter::make('range')), // two number inputs
Use syncWith() as a shortcut surface, not a second filter. This is the pattern worth internalising: define the real filter once in getTableFilters() (with whatever query logic, defaults, and visibility you need), then bind a header popup to it by name. The popup reads and writes that filter's state, so the standard filters dropdown, the indicators, and the header icon all stay in agreement.
SelectFilter::make('status')
->options(StatusEnum::class)
->multiple(),
// on the column:
TextColumn::make('status')
->columnFilter(ColumnFilter::make('select')->syncWith('status')),
The payoff is that nothing lives in two places: business logic stays in the Filament filter, and the header popup is pure affordance. It also gives you a graceful rollout — ship the regular filter first, then layer the header popup on top.
Prefer syncWith() for anything non-trivial. Unsynced header filters are convenient but their state is owned by the package. If you need a default, a modified query, a ->query() closure, or programmatic manipulation, define a real filter and sync to it. Treat standalone column filters as the "quick internal admin" tier.
Date presets are the differentiator; lean on them. Today / yesterday / this week / last week / this month / last month / last 7 days / last 30 days / this year / last year plus a custom from/until covers almost every "when did this happen" question. On dashboards and ledgers this replaces a custom date-range widget and a chunk of UI code. Use the Range type for money, stock, or duration columns where users think in "between X and Y" terms.
Factor repeated definitions. Once three or four tables use the same header filters, extract them:
trait HasColumnFilters
{
protected static function dateColumn(string $name): TextColumn
{
return TextColumn::make($name)
->dateTime()
->columnFilter(ColumnFilter::make('date'));
}
}
Test at the Livewire layer. Assert on filter state and rendered rows, not on the popup markup: set the synced filter, call the table, assert the record set. Also assert the indicator renders and that removing it restores the unfiltered set — that is the contract users notice when it breaks.
RTL / Hebrew. If you serve Hebrew-speaking users, this is a genuine reason to pick this package over hand-rolling: full RTL support and Hebrew translations are included, and Filament's own RTL story in table headers is uneven.
Treat it as vendored code you own. 21 stars, zero dependents, and a two-artefact maturity profile (README + CHANGELOG) means no community safety net. The integration point — a filter icon in Filament's column header slot — is exactly the kind of thing that breaks on a Filament minor bump. Pin the constraint in composer.json, and budget for reading the diff on every Filament upgrade. The MIT license at least means you can fork it into app/ or a private packages/ directory if upstream goes quiet. Do that deliberately, early, rather than in a panic during an upgrade.
Confirm the Filament major before scoping. Filament v3 and v4 differ in table internals. Check the CHANGELOG and the composer.json requirement, then verify with a real table in a real panel — a clean composer require is not proof the header slot renders. Put this on day one of the spike, not day five.
syncWith() fails quietly on a name mismatch. The string must match the filter's ->getName() exactly, including any implicit naming from the filter's key. If the popup appears to do nothing, dump the live filter state and compare:
// in a Tinker/Livewire context, or temporarily in the table class
dump($this->getTableFilters());
A typo, or syncing to a filter that is ->hidden() or conditionally not registered for the current user, produces a dead popup rather than an exception.
Watch for double sources of truth. If a synced filter has a default ->default(...), loading the table already shows the indicator; make sure the header popup reflects that state and that clearing it in one place clears it in the other. Test both directions explicitly: popup → dropdown, dropdown → popup.
Search is a LIKE query. Free-text header search on a large table is a sequential scan waiting to happen. Keep it on low-cardinality or indexed columns, and on big tables prefer Select against an enum, or scope the table down before the user searches. A header icon makes searching easier, which means it will be used more than the dropdown filter was — plan for the query volume.
Date ranges and timezones. A range that "looks right" in the popup can select the wrong rows around midnight if the app timezone and the storage timezone differ. Confirm how the package builds the range boundaries (inclusive until, day-level vs. timestamp-level) before you sign off on finance or audit screens.
UI clutter and collisions. On dense tables every header gains an icon; on mobile the popup positioning can be awkward. Don't stack it with a custom header action on the same column — verify the slot renders both, or move one. Consider enabling it only on the tables where filtering is a primary workflow.
Extension points are in the source, not the README. The four filter-type classes are the natural place to subclass if you need a different popup shape or query semantics; publish/override views if the package exposes them. Read the classes before writing a workaround — a small package usually has a smaller seam than you expect, and forking a single class is cheaper than fighting the abstraction.
Version-lock your knowledge. Because the README is thin, write a short internal note in the repo (which Filament version, which column filter types you use, which filters are synced) when you adopt it. The next developer — or you in six months — will otherwise reconstruct it from vendor/ during an upgrade.
How can I help you explore Laravel packages today?