suenerds/nova-searchable-belongs-to-filter
This Laravel Nova package replaces BelongsTo relationship filter dropdowns with a searchable, typeahead-style input, so you can quickly find and filter by related records even when the underlying table contains thousands of options.
What it solves. Nova's stock Laravel\Nova\Filters\BelongsTo renders a <select> containing every row of the related table. On a users table with 50k rows, that's a slow index page and an unusable dropdown. This package swaps the select for an async type-ahead search box — same filter semantics, no eager-loaded option list. If your related tables are small, the native filter is fine and this package adds nothing.
Install.
composer require suenerds/nova-searchable-belongs-to-filter
No config file, no migrations, no artisan install command. Immediately after install, run:
php artisan nova:publish
and hard-refresh the Nova index page. If the filter renders but looks unstyled, or the search box is inert, the compiled JS isn't registered — fix that before debugging anything else.
Version note. v2.0.3 is a compatibility-only patch: it adds Laravel 11 to the composer.json constraint. No API changes, no new setters, no deprecations — every snippet below works identically across 2.0.0–2.0.3. If you're on Laravel 11, require ^2.0.3 specifically, since earlier tags don't declare support. On Laravel 10 the bump buys you nothing; pin whatever 2.0.x you already run. Run composer why-not during adoption to catch constraint conflicts early.
Where to look first.
src/SearchableBelongsToFilter.php — the public setters (filterBy, searchColumns, and friends) plus the apply() implementation. This is the contract you code against.resources/js/components/Filter.vue — shows which Nova endpoint the search box hits, whether there's a debounce and a result cap, and whether scoping/authorization comes from the related Nova resource's indexQuery or from something you must supply yourself.First use case. A Post resource whose author belongsTo User. Replace the stock filter in app/Nova/Post.php:
use Suenerds\SearchableBelongsToFilter\SearchableBelongsToFilter;
public function filters(NovaRequest $request)
{
return [
SearchableBelongsToFilter::make('Author')
->filterBy('author') // relationship method name on the Post model
->searchColumns(['name', 'email']), // columns the search box queries
];
}
The stored filter value remains a scalar related-model ID, so the filtered index behaves exactly like the old filter — only the picking UI changed. That's why migration is one line per resource, and why existing bookmarked filter URLs keep resolving.
Pick the right first target. Start with the biggest related table, not the most complex resource. The value scales with cardinality — target the resource where admins currently scroll a 10k-row dropdown. Blast radius is nil (one line, one-line revert), so there's no justification for piloting on something trivial.
1. Extract reusable filter classes. The moment you use the same relationship in three resources (author on Post, Comment, Draft), stop duplicating the chain. Bake it into app/Nova/Filters/AuthorFilter.php:
<?php
namespace App\Nova\Filters;
use Suenerds\SearchableBelongsToFilter\SearchableBelongsToFilter;
class AuthorFilter extends SearchableBelongsToFilter
{
public function __construct()
{
parent::__construct();
$this->filterBy('author')
->searchColumns(['name', 'email']);
}
}
Resources then just list new AuthorFilter. One place to change search columns when you add a username column to users.
2. Choose search columns that match what admins actually type — and index them. Default to the columns support staff already use as identifiers: name, email, account_number, external_id. If you search an email column, make sure it's indexed; a LIKE '%term%' scan on a 500k-row table turns a snappy filter into a timeout. For genuinely large tables, add a dedicated indexed search column (or a trigram index) rather than searching free-text columns directly.
3. Combine with native filters freely. This filter is a drop-in for one belongsTo and nothing else. Keep stock Boolean, Date, and SelectFilter instances alongside it. Don't register two searchable filters for the same relationship in one filters() array — the UI has no way to disambiguate which one is active.
4. Verify tenant scoping and lenses end-to-end. Because the search ultimately queries the related model through Nova, anything the related resource's indexQuery applies (global scopes, tenancy, soft-delete rules) should carry through. Verify explicitly rather than trusting it. The failure mode is a search that surfaces records from another tenant — a security issue, not a UX one. Test with a second tenant's user logged in before signing off, and use the same setup to confirm lens constraints carry into search results.
5. Override apply() when the setters aren't enough. There's no config file and no event system — extension is plain PHP inheritance. Subclass and override apply() to add query constraints, cap results, or handle multi-column matching:
use Illuminate\Contracts\Database\Eloquent\Builder;
public function apply(NovaRequest $request, $query, $value)
{
return $query->whereHas('author', function ($q) use ($value) {
$q->where('tenant_id', $request->user()->tenant_id);
});
}
That's the whole extension model — both the package's humility and its ceiling. Complex multi-relationship or multi-select filter logic is out of scope and must be built yourself.
6. Write a smoke test. Add an acceptance test that visits a Nova index with the filter applied and asserts the expected records appear — and that an out-of-scope user's records do not. This is the cheapest insurance against the package's three biggest risks: asset registration, authorization scope, and version drift on Nova upgrades.
7. Run a two-way UAT with the ops team. Have an admin complete the real task — "find the post by author Jordan Lee" — with the old filter and the new one. Measure clicks and seconds. That's the number you put in the release note and the argument for rolling out to the next resource.
8. Pin it and put it in the upgrade runbook. Pin the exact version in composer.json, name an owner, schedule a quarterly review, and document two things: re-test the Nova index page after every Nova/Laravel major bump, and the one-line fallback to Laravel\Nova\Filters\BelongsTo. Given a 29-star, single-maintainer, zero-dependent package, treat the source as the spec — read Filter.vue and SearchableBelongsToFilter.php end-to-end (it's an afternoon) before adopting, and keep forking realistic in your back pocket.
Assets are the #1 source of "it's broken." Filter visible but search box inert or unstyled means the JS wasn't compiled/registered in your Nova build. Run php artisan nova:publish, hard-refresh, and check the browser network tab to confirm the filter bundle loaded. If you run a custom Nova asset pipeline, verify the package's compiled assets actually ship in the vendor directory — if a Node build step against the Nova toolchain is required, budget for it in CI.
Laravel 11 support ≠ Nova 5 support. The v2.0.3 changelog only touches the Laravel constraint. Confirm the Nova version you're on before assuming compatibility. Nova's and Laravel's release cadences aren't coupled, so a Nova upgrade can break the filter even on a nominally compatible Laravel version.
Verify authorization explicitly — don't assume. Search results usually flow through Nova's index query for the related resource, but confirm rather than trust. Two tests: (a) tenant A's user searching for tenant B's records, (b) does an admin without viewAny on the related resource get results back from the search endpoint? If yes, that's an authorization gap the package can't fix for you — restrict via policy or an apply() override.
filterBy takes the relationship method name, not the foreign key column. ->filterBy('author') means $post->author() exists. Passing author_id will silently misbehave. If a filter "applies but returns nothing," this typo is the first thing to check — along with a misspelled related model namespace.
Empty and pre-selected states. A filter with no selection must mean "no constraint," not "no results." A filter with a saved value (bookmarked URL, returning user) must render the selected record's label without a search round-trip. Test both by reloading the index page with a filter ID in the query string. If the label is blank while the filter correctly constrains results, you're hitting a rendering/formatting gap — the filter applies, but the display is broken.
Result limits and debounce. Large-table search endpoints become query hotspots. Check Filter.vue to see whether there's a result cap and a debounce; if the package doesn't cap results, add a ->take()-style constraint via a scope or an apply() override. Test against a restored production dump, not a seeded dev database — a filter that works on 5k records may time out on 500k.
Debugging the search endpoint. When results come back wrong, don't poke at the Vue component. Open the browser network tab, capture the actual request the search box sends, and replay that query against the related model's Nova index route. Half the time the problem is a global scope or a missing searchable attribute on the model — better found in the network tab than in the source.
Localization. The package is a thin Nova wrapper, but the JS component is small enough that strings are often hardcoded English. If your admin panel is localized, verify the placeholder and empty-state strings render correctly.
Treat the source as the spec. 29 stars, zero dependents, single maintainer, no test suite. Read the two source files, write the smoke test above, pin the version, and keep the one-line revert to Laravel\Nova\Filters\BelongsTo documented in the admin runbook. Cheap to adopt, cheap to abandon — that's exactly what makes it a defensible dependency rather than a risky one.
How can I help you explore Laravel packages today?