The Problem With Default Global Search in Multi-Tenant Panels
Filament's built-in global search is powered by GloballySearchable on your Eloquent resources. Drop the trait on a resource, define getGlobalSearchResultTitle(), and you're done — until you're running a multi-tenant SaaS.
The moment you have multiple tenants sharing a panel, the default implementation will happily return results across every tenant's data unless you've already applied a global scope. Even then, surfacing results from non-Eloquent sources (an Elasticsearch index, a Redis cache, a third-party API) is simply not possible through the trait alone.
This article shows you how to register a fully custom GlobalSearchProvider, scope it to the authenticated tenant, and keep it testable.
How Filament Resolves Search Results
Filament's GlobalSearch Livewire component calls FilamentManager::getGlobalSearchResults(string $query). Internally it iterates over every registered resource that implements HasGlobalSearch and merges their result collections.
What most developers miss: you can replace that resolution entirely by binding your own implementation to the GlobalSearch contract in a service provider.
// app/Providers/AppServiceProvider.php
use Filament\GlobalSearch\Contracts\GlobalSearchProvider;
use App\Search\TenantAwareSearchProvider;
public function register(): void
{
$this->app->bind(GlobalSearchProvider::class, TenantAwareSearchProvider::class);
}
Building a Tenant-Scoped Provider
<?php
namespace App\Search;
use Filament\GlobalSearch\Contracts\GlobalSearchProvider;
use Filament\GlobalSearch\GlobalSearchResults;
use Illuminate\Support\Facades\Auth;
class TenantAwareSearchProvider implements GlobalSearchProvider
{
public function getResults(string $query): ?GlobalSearchResults
{
$tenant = Auth::user()?->currentTeam;
if (! $tenant || strlen(trim($query)) < 2) {
return null;
}
$builder = GlobalSearchResults::make();
// Eloquent source — always scope to tenant
$projects = \App\Models\Project::query()
->whereBelongsTo($tenant)
->globalSearch($query) // local scope on the model
->limit(5)
->get();
$builder->category(
label: 'Projects',
results: $projects->map(fn ($p) => [
'title' => $p->name,
'description' => $p->client->name ?? null,
'url' => route('filament.admin.resources.projects.edit', $p),
])
);
// Non-Eloquent source — e.g. a cached knowledge base
$articles = app(\App\Search\KnowledgeBaseSearch::class)
->forTenant($tenant->id)
->query($query)
->take(3);
$builder->category(
label: 'Help Articles',
results: collect($articles)->map(fn ($a) => [
'title' => $a['title'],
'url' => $a['url'],
])
);
return $builder;
}
}
The globalSearch Local Scope
Keep the Eloquent query logic on the model, not in the provider:
// app/Models/Project.php
public function scopeGlobalSearch(Builder $query, string $term): Builder
{
return $query->where(function (Builder $q) use ($term) {
$q->whereFullText(['name', 'description'], $term)
->orWhere('name', 'like', "%{$term}%");
});
}
Using whereFullText on a MySQL/PostgreSQL full-text index keeps things fast at scale without pulling the filtering into PHP.
Testing the Provider in Isolation
Because the provider is a plain class bound via the container, Pest makes testing trivial:
it('returns only current tenant projects', function () {
$tenant = Team::factory()->create();
$other = Team::factory()->create();
$mine = Project::factory()->for($tenant)->create(['name' => 'Alpha']);
$theirs = Project::factory()->for($other)->create(['name' => 'Alpha']);
$user = User::factory()->for($tenant, 'currentTeam')->create();
$this->actingAs($user);
$results = app(\Filament\GlobalSearch\Contracts\GlobalSearchProvider::class)
->getResults('Alpha');
$urls = collect($results->getCategories())
->flatMap(fn ($c) => $c['results'])
->pluck('url');
expect($urls)->toHaveCount(1)
->and($urls->first())->toContain((string) $mine->id);
});
Key Takeaways
- Bind a custom class to
GlobalSearchProvider::classto fully own global search logic. - Always scope queries to the authenticated tenant inside the provider — never rely on a global scope alone.
- Push Eloquent filtering into local model scopes; keep the provider thin and orchestration-focused.
- Non-Eloquent sources (APIs, caches, search engines) fit naturally into
GlobalSearchResults::category(). - The provider is a plain PHP class — unit-test it without booting the full Filament panel.