philperusse/nova-column-filter
philperusse/nova-column-filter adds configurable column filters to Laravel Nova resource index tables, letting users narrow records by specific fields directly from the Nova UI for faster, more focused browsing.
Confirm it installs before you write any code. The last release is 2019-02-25, which puts the package in the Nova 1.x/2.x, Laravel 5.7–6, PHP 7.2–7.4 window. The cheapest possible answer to "is this viable for us" is a constraint check:
composer require philperusse/nova-column-filter --dry-run
If Composer resolves against your pinned laravel/nova and PHP version, install for real. If it conflicts, stop — port the pattern instead (see below). Do not spend time reading the source before you know this.
Where to look first, in order:
src/ — the ColumnFilter class. This is the whole package: a Nova Filter subclass with a column allowlist and an apply() that builds a WHERE. Read it end to end; it is short enough to hold in your head.composer.json — the exact Nova/Illuminate/PHP constraints. This tells you what era it was built for.resources/js/ and any compiled dist/ asset — determines whether you need to publish assets and whether the frontend half will render under your Nova build.README.md — for the exact make() / columns() signature, since it is the only authoritative source for the fluent API.First use case — a status/owner dropdown on an internal resource. Register the filter in the resource's filters() method with a static, developer-defined column list:
use Philperusse\NovaColumnFilter\ColumnFilter;
public function filters(Request $request)
{
return [
ColumnFilter::make('Columns')
->columns([
'status',
'owner_id',
'is_archived',
]),
];
}
The shape of the API above is the pattern to expect — verify the exact method names against src/ before committing, because the README is the only place the current signature is documented. Then load the resource index in the browser and confirm the filter appears in the Nova filter panel before testing that it actually constrains results.
Do not reach for the package on a customer-facing surface. It is an admin-panel convenience and belongs only there.
Treat the filter as a per-resource allowlist, not a global switch. The value of the package is that you decide, per resource, which columns a user may query. Keep that list short, static, and indexed:
ColumnFilter::make('Columns')
->columns([
'status', // indexed enum
'owner_id', // indexed FK
'created_at', // indexed timestamp
])
Never derive the column list from request input, a config table, or Schema::getColumnListing() at runtime. The whole safety story of this package rests on the column and operator sets being developer-defined constants. The moment a column name can be influenced by a user, you have SQL injection and cross-tenant disclosure inside the admin panel — a hard stop, not a fork-and-fix.
Two filters, two jobs. If a resource genuinely needs arbitrary querying and a fast answer to a recurring question, ship both: the column filter for the power user, and a normal hand-written Nova filter for the common case:
public function filters(Request $request)
{
return [
new Filters\ActiveOnly, // curated, index-aware, one click
ColumnFilter::make('Columns')->columns([...]), // ad-hoc, for the power user
];
}
This keeps the common path one click and the uncommon path available, without the query builder becoming the default mental model.
Subclass it rather than configuring it everywhere. If several resources want the same allowlist and the same operator behaviour, put a thin base class in app/Nova/Filters:
namespace App\Nova\Filters;
use Philperusse\NovaColumnFilter\ColumnFilter;
abstract class SafeColumnFilter extends ColumnFilter
{
abstract protected function allowedColumns(): array;
public function columns(): array
{
return $this->allowedColumns();
}
}
This is also the seam you use when you eventually port: swap the parent class for your own implementation and every resource keeps working. Keeping the extends in exactly one place is what makes the exit cheap.
Wire it to Nova lenses, not just the index. A lens is already a curated query. Adding a column filter on top of a lens is where day-to-day value shows up — the lens fixes the scope, the column filter narrows within it. Verify the generated predicate composes correctly with the lens's own constraints rather than replacing them.
Integration tips that pay off repeatedly:
dd() the query once during rollout with ->toSql() and getBindings() to confirm bindings are used and the column is not interpolated into the SQL string.Silent non-rendering is the failure mode to plan for. A Nova filter that fails to register or whose asset fails to publish typically produces no error — it just is not there. Users conclude the capability does not exist and stop asking. Test rendering explicitly in a real browser against your exact Nova build; do not assume a green PHP test means the dropdown appears.
The frontend half and the PHP half fail independently. The apply() signature has been broadly stable across Nova 2–4, but asset registration and filter metadata have not. Nova 4's Vite-era build tooling is the likeliest break point for a 2019 compiled asset. Budget the asset spike separately from the query spike, and if you must check assets require a publish step, confirm it survives your deploy pipeline (nova:publish in the release script, not on someone's laptop).
Never let a LIKE '%…%' reach a large table. Generic column querying invites unindexed WHERE clauses, LIKE scans, and correlated subqueries. If a column is high-cardinality free text, leave it out of the allowlist or add a generated/fulltext index first. Measure on your largest resource table before rollout — if query time regresses, the fix is a shorter allowlist, not a faster server.
Global scopes, soft deletes, and tenancy need a test, not a review. The base Nova index query usually carries these, but predicates appended inside apply() can bypass them depending on how the query is built. Write the test that asserts a soft-deleted or other-tenant row does not appear in results when a column filter is applied. This is the one place where a bug is a data exposure rather than a UX annoyance.
Pin your exit before you start. With no release since 2019, no dependents, and a single maintainer, every future Nova or PHP major becomes unplanned work on your team. Two options:
app/Nova/Filters — roughly 150 lines of PHP plus a small UI — and keep the MIT attribution in a header comment. This is the recommended path, and it is under a day for a developer fluent in Nova and Eloquent.Do not add it to composer.json as a routine dependency unless a team is prepared to own the fork. Adopting a 2019-era, zero-dependent package is a decision to own unmaintained code — make that a named line item with an owner, not an assumption.
Extension points are thin, which is the point. The class exposes the column allowlist and apply(). Anything beyond that — custom operators, per-column input types, saved views, compound filters, exports — is not a package feature and should not be forced into one. If the roadmap genuinely wants slicing and saved reports, that belongs in a purpose-built surface (a BI tool or an internal dashboard), not in Nova filters. Nova is the admin surface; keep it there.
How can I help you explore Laravel packages today?