reedware/nova-text-filter
A Laravel Nova filter that adds a free-text search field to your resource index, letting users filter records by typing keywords across selected columns. Lightweight, configurable, and easy to drop into any Nova resource for quick, intuitive searching.
This package is only useful if you are already running Laravel Nova and have a resource or lens index screen where a dropdown filter is the wrong shape for the question users are asking. It gives you a text input in Nova's filter panel that applies a LIKE constraint against a column you choose. It is not general Laravel search, and it does not touch Nova's global search.
Install and confirm discovery:
composer require reedware/nova-text-filter
composer show laravel/nova # note your exact Nova major
Check the package's composer.json for extra.laravel.providers. With discovery enabled (default) the provider registers itself; otherwise add it to config/app.php. The last release is April 2023, so verify the constraint actually admits your Nova/Laravel/PHP matrix before you build anything on top of it.
Where to look first, in order:
README.md — the exact base class namespace (Reedware\NovaTextFilter\TextFilter), the publish tag for frontend assets, and the placeholder/label options.src/ — three things you cannot safely guess: the default component property (the Vue component name that must resolve on the frontend), the apply() signature (NovaRequest vs. the older Illuminate\Http\Request, which changed across Nova majors), and any built-in column or matching hooks.First use case: pick one low-risk internal resource with a modest table. Scaffold with Nova, then reparent:
php artisan nova:filter UserEmailFilter
namespace App\Nova\Filters;
use Laravel\Nova\Http\Requests\NovaRequest;
use Reedware\NovaTextFilter\TextFilter;
class UserEmailFilter extends TextFilter
{
public $name = 'Email contains';
public function apply(NovaRequest $request, $query, $value)
{
if (blank($value)) {
return $query;
}
return $query->where('email', 'like', '%'.addcslashes($value, '%_\\').'%');
}
}
Register it in filters() on the resource. The happy path is that short. The real work is verification: the input mounts on the index screen, blank input returns the unfiltered set, and the query plan is acceptable on production-like data.
One abstract base class in your app, concrete filters of a few lines each. This is the highest-leverage pattern with this package. The package gives you a text input wired into Nova's filter lifecycle; everything subtle — blank handling, LIKE escaping, case sensitivity, column selection — belongs in code you control. When you fork, replace, or upgrade the package, you change one parent class, not forty filters.
namespace App\Nova\Filters;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Laravel\Nova\Http\Requests\NovaRequest;
use Reedware\NovaTextFilter\TextFilter;
abstract class AppTextFilter extends TextFilter
{
abstract protected function columns(): array;
public function apply(NovaRequest $request, $query, $value)
{
if (blank($value)) {
return $query; // never let '' become LIKE '%%'
}
$term = '%'.addcslashes($value, '%_\\').'%';
return $query->where(function (Builder $q) use ($term) {
foreach ($this->columns() as $column) {
$q->orWhere($column, 'like', $term);
}
});
}
}
Concrete filters then become trivial, and reviewers can read them in one sitting:
class CustomerNameFilter extends AppTextFilter
{
public $name = 'Name or email';
protected function columns(): array
{
return ['name', 'email'];
}
}
Always wrap multi-column matches in a closure. The where(fn ($q) => ...orWhere...) grouping above is not stylistic. An ungrouped orWhere ORs itself past authorization scopes, soft-delete constraints, and every other active filter — and the symptom is not "the filter is broken," it is "the filter shows records this user shouldn't see," discovered weeks later.
Use qualified column names for relationships. The package supports them, so lean on it rather than hand-rolling joins:
protected function columns(): array
{
return ['users.name', 'users.email']; // requires the join to exist upstream
}
If the relationship is not already joined in the index query, add a ->join() in your resource's indexQuery() — not inside apply(), where it will fire repeatedly and blow up aggregates.
Expose a match mode instead of hardcoding %term%. The package advertises configurable matching; surface it in your base class so "starts with" and "exact" don't become copy-pasted variant filters:
protected function match(string $mode, string $value): array
{
$escaped = addcslashes($value, '%_\\');
return match ($mode) {
'starts' => ['like', $escaped.'%'],
'exact' => ['=', $value],
default => ['like', '%'.$escaped.'%'],
};
}
Resources and lenses share the same class — but test both. Register the same instance on a lens's filters(). Lens screens are where frontend component resolution most often breaks, and a filter that renders on the resource but not the lens is a staging-only discovery if you skip that screen.
Route around LIKE before it becomes a problem. On high-volume resources, keep the identical TextFilter UI and send the query elsewhere inside apply() — Scout, Meilisearch, or Postgres to_tsvector. Leading-wildcard LIKE cannot use a B-tree index; that is a design decision, not a tuning exercise. Pick the row-count threshold before rollout and document which resources are permitted to use the filter.
Use it as a probe, not a platform. Ship on one or two internal resources, instrument index-request usage, gather feedback. If admins use it, you have evidence to greenlight faceted filters, saved views, or external search. If they don't, you spent a day. Frame it that way so it never gets scoped into a "search roadmap" conversation.
Blank input is the most common production incident with this class of filter. If apply() doesn't short-circuit on blank(), an empty field becomes LIKE '%%' — a full-table scan dressed as "no filter." Guard it in the base class so no concrete filter can forget.
Escape user input before interpolating into LIKE. A user typing 50% or a_b silently broadens matches. Use addcslashes($value, '%_\\'), and on Postgres and SQL Server add an explicit ESCAPE clause — the escape character is not implicit there the way it often is on MySQL.
Case sensitivity is driver- and collation-dependent. MySQL's default utf8mb4_unicode_ci gives case-insensitive LIKE; Postgres LIKE is case-sensitive and you want ILIKE or lower() on both sides. Develop on MySQL, deploy to Postgres, and your filter "works" locally while behaving differently in production. Settle it once, in the base class, against your actual driver.
The apply() signature differs across Nova majors. Older Nova used Illuminate\Http\Request; newer uses Laravel\Nova\Http\Requests\NovaRequest. With a 2023 release, confirm what the abstract class declares against what nova:filter generates. A mismatch produces a Declaration must be compatible fatal — cheap to fix, but a full stop on deployment day.
Missing frontend assets look like a broken package. A raw HTML input or a component that fails to mount is almost always assets, not code:
php artisan vendor:publish --tag=nova-text-filter-assets
php artisan nova:publish
If it still doesn't mount, compare the component property on the base class against what exists in public/vendor/ — a Vue component name that no longer resolves after a Nova upgrade fails silently.
Treat every Nova upgrade as a revalidation event. Put this filter on the upgrade checklist by name. One release in 2023 and no dependents means assume limited upstream maintenance; a Nova major bump is the likeliest breakage moment, and the failure mode is visible enough to catch in staging if it's on the list.
Decide the fallback before broad rollout, not after. The blast radius is small — a UI filter on admin index screens — so keep the mitigation proportionate: agree now whether you'd vendor the component, fork it, or replace it with the ~30-line base class above. Name an internal owner and note the license. Support is community-only; the escalation path must be internal by design.
Version-pin and commit the lockfile. Pin the exact version, commit composer.lock, and run your staging spike against the exact Nova/Laravel/PHP matrix you deploy. Weak adoption signals don't disqualify the package, but they do mean "composer require succeeded" carries no compatibility guarantee.
Replacing an existing custom filter? Run both in parallel in staging first. Compare result sets and query plans on representative records before switching. Where no filter exists today, roll out additively behind a feature flag — there's no migration burden and no reason to gate it harder than that.
How can I help you explore Laravel packages today?