Filament v3 Global Search: Custom Result Providers and Scoped Tenant Isolation
#filament #laravel #multi-tenant #search

Filament v3 Global Search: Custom Result Providers and Scoped Tenant Isolation

1 min read Mohamed Said Mohamed Said

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::class to 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Will replacing the GlobalSearchProvider break Filament's built-in resource search?
Yes — once you bind a custom provider, you own all result aggregation. You can replicate the default resource-scanning behaviour by iterating `Filament::getResources()` and checking for `HasGlobalSearch`, then merging those results alongside your custom categories.
Q02 How do I control the order of result categories in the dropdown?
Categories appear in the order you call `->category()` on the `GlobalSearchResults` builder. Simply reorder your calls, or sort the source collections before mapping them, to control display priority.
Q03 Is there a performance risk running multiple queries on every keystroke?
Filament debounces the search input (500 ms by default). You can raise this via `$panel->globalSearchDebounce('750ms')` in your PanelProvider. Additionally, add database indexes on searched columns and consider caching expensive third-party lookups with a short TTL.

Continue reading

More Articles

View all