Filament v4 Global Search: Custom HTML Result Titles and Inline Actions

Customise Filament v4 global search with HtmlString badges, inline Action buttons, and per-tenant scoping. Production-ready patterns for any Filament resource.

Steven Richardson
Steven Richardson
· 7 min read

Filament's global search works the moment you set $recordTitleAttribute, but the default result row is a single line of plain text, no badges, no actions, no tenant awareness. Four overrides on your resource turn it into a proper command-palette experience that respects who is logged in. Here is the full pattern, exactly as I ship it.

Enable global search on the Post resource#

Global search activates on a resource as soon as you set the title attribute. Pick the column that uniquely identifies a record to a human, usually name or title. The resource also needs an Edit or View page registered, otherwise Filament returns zero results and there is nothing to click. If your panel doesn't have one yet, my zero-to-production Filament v4 dashboard walkthrough covers the resource scaffolding end-to-end.

namespace App\Filament\Resources\Posts;

use Filament\Resources\Resource;

class PostResource extends Resource
{
    protected static ?string $model = \App\Models\Post::class;

    protected static ?string $recordTitleAttribute = 'title';
}

That single line is enough to make every Post row searchable from the topbar. Hit ⌘K, type a title, click, Filament routes you to the edit page. Everything from here is improvement on that baseline.

Search across multiple columns and relations#

The default behaviour only searches the $recordTitleAttribute column. Real apps want the slug, the body, the author name, and the category name in scope. Override getGloballySearchableAttributes() and return every column you want indexed, dot notation pulls in related models.

public static function getGloballySearchableAttributes(): array
{
    return [
        'title',
        'slug',
        'author.name',
        'category.name',
    ];
}

Filament builds a LIKE query against each attribute and unions the matches. Be careful with very wide bodies, searching posts.body on a million-row table will hurt. For high-volume search, push it out to Scout instead; I cover that trade-off in Typesense typo-tolerant search with Laravel Scout. For an admin panel, native LIKE is usually fine.

Render HTML badges in the result title#

This is the override most devs miss. getGlobalSearchResultTitle() is type-hinted as string | Htmlable, so returning an HtmlString instance lets you inject a status badge, a pill, or even an inline SVG into the result row. Pull whatever metadata you want from the record and compose the markup yourself.

use Filament\Resources\Resource;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;

public static function getGlobalSearchResultTitle(Model $record): string | Htmlable
{
    $badge = match ($record->status) {
        'published' => '<span class="ml-2 inline-flex items-center rounded-md bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-green-600/20">Published</span>',
        'draft'     => '<span class="ml-2 inline-flex items-center rounded-md bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700 ring-1 ring-yellow-600/20">Draft</span>',
        'archived'  => '<span class="ml-2 inline-flex items-center rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600 ring-1 ring-zinc-500/20">Archived</span>',
        default     => '',
    };

    return new HtmlString(e($record->title) . $badge);
}

Always run user-controlled values through e() before concatenating them into the HtmlString. The badge markup is yours so it can be trusted, but the title comes from the database and must be escaped, otherwise you have built an XSS hole into your admin search.

Add subtitle details to each result#

Long titles alone are not always enough to disambiguate. getGlobalSearchResultDetails() returns an associative array that Filament renders as a definition list below the title. I use it for the author, the category, and the updated-at timestamp on content-heavy resources.

public static function getGlobalSearchResultDetails(Model $record): array
{
    return [
        'Author'   => $record->author?->name ?? ',',
        'Category' => $record->category?->name ?? 'Uncategorised',
        'Updated'  => $record->updated_at?->diffForHumans() ?? ',',
    ];
}

The labels are the dictionary keys, so they show up exactly as written. Keep them short, three to five entries max, otherwise the dropdown turns into a wall of text. The same dot-notation relations work here as in the searchable attributes.

Attach inline actions to results#

Each search result can carry a row of buttons. getGlobalSearchResultActions() returns an array of Filament\Actions\Action instances, the same unified Action class Filament v4 uses everywhere else, so the same modal, form, and confirmation APIs apply. If you have not seen the unified Action API yet, my walkthrough of custom Filament v4 actions with confirmation modals and notifications covers the full surface.

use Filament\Actions\Action;
use Illuminate\Database\Eloquent\Model;

public static function getGlobalSearchResultActions(Model $record): array
{
    return [
        Action::make('edit')
            ->icon('heroicon-m-pencil-square')
            ->url(static::getUrl('edit', ['record' => $record])),

        Action::make('view')
            ->icon('heroicon-m-eye')
            ->url(static::getUrl('view', ['record' => $record]), shouldOpenInNewTab: true),

        Action::make('archive')
            ->icon('heroicon-m-archive-box')
            ->requiresConfirmation()
            ->action(fn () => $record->update(['status' => 'archived'])),
    ];
}

Actions that mutate state, like archive here, should still go through your normal policy and validation pipeline. The action runs in the Livewire request that owns the topbar, so $this->authorize('archive', $record) works exactly as you would expect. You can also fire a Livewire event with ->dispatch('quickView', [$record->id]) if you want a modal handled elsewhere in the page.

Scope global search per tenant#

If your panel uses Filament's built-in multi-tenancy, resources scoped to the current tenant are already filtered through getEloquentQuery() automatically. But global search uses its own query method, and you control it explicitly. Override getGlobalSearchEloquentQuery() and apply the tenant constraint there, never add a global scope on the model itself, because that breaks the public site, queue workers, and seeders.

use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;

public static function getGlobalSearchEloquentQuery(): Builder
{
    return parent::getGlobalSearchEloquentQuery()
        ->where('team_id', Filament::getTenant()?->getKey());
}

parent::getGlobalSearchEloquentQuery() returns the base resource query, so you keep any soft-delete and global eager-loads from getEloquentQuery(). The Filament::getTenant() call resolves the current panel tenant, if you are still wiring tenancy up, I walk through the full setup in the Filament v4 multi-tenancy team panel guide.

Eager-load relations referenced by the title or details#

Every relation you touch inside getGlobalSearchResultTitle(), getGlobalSearchResultDetails(), or getGlobalSearchResultActions() is loaded lazily by default. That means an N+1 query on every keystroke, fifty results, three relations, one hundred and fifty queries fired in a 500ms debounce window. Bolt the eager-loads onto the same query override you already use for tenant scoping.

public static function getGlobalSearchEloquentQuery(): Builder
{
    return parent::getGlobalSearchEloquentQuery()
        ->with(['author', 'category'])
        ->where('team_id', Filament::getTenant()?->getKey());
}

For pivot relations you also need to eager-load, the Filament v4 relation manager with many-to-many pivot data article shows the syntax for nested with chains. The cost of ->with() is one extra query total per search, instead of one per result, well worth it.

Gotchas and Edge Cases#

Three things bite people in production. First, the SPA mode regression in filament/filament#14196: when you switch tenant via the tenant menu without a hard reload, the global search component does not re-mount, and the previous tenant's results stick around. Either disable SPA mode on the tenant switcher or force a full navigation. Second, search term splitting, $shouldSplitGlobalSearchTerms defaults to true, which means "red car" searches for red AND car. On large tables with no full-text index this is brutal; set it to false if you see slow queries in your Telescope log. Third, getUrl() requires the resource to register a page that matches, if you ripped out the View page but still reference it in your actions, the result row breaks silently.

Wrapping Up#

You have four overrides to remember, getGloballySearchableAttributes, getGlobalSearchResultTitle, getGlobalSearchResultDetails, getGlobalSearchResultActions, and one query method, getGlobalSearchEloquentQuery, that does double duty for tenant scoping and eager-loading. Ship it on one resource first, watch your query log for N+1, then roll it out across the panel. If you outgrow native LIKE search, the next step is migrating to Scout with Typesense for typo-tolerant search, same overrides, different query backend.

FAQ#

How do I enable global search in Filament v4?

Set protected static ?string $recordTitleAttribute = 'title'; on your Resource class and make sure the resource has an Edit or View page registered. That single property is enough to make every record searchable from the topbar, Filament queries the title column with LIKE and routes clicks to the edit page automatically.

How do I add badges or icons to global search results in Filament?

Override getGlobalSearchResultTitle(Model $record) and return an instance of Illuminate\Support\HtmlString. The method signature accepts string | Htmlable, so you can compose your own markup, a Tailwind pill, an inline SVG, anything. Always run user-controlled values like the record title through the e() helper before concatenating, otherwise you open an XSS vector on the admin search.

Can I add action buttons inside Filament global search results?

Yes. Override getGlobalSearchResultActions(Model $record) and return an array of Filament\Actions\Action instances. Each Action renders as a button inline on the result row, and the full Action API is available, you can attach ->requiresConfirmation(), ->url(), ->action() closures, or ->dispatch() for a Livewire event. Authorisation runs through the same policy pipeline as any other action.

How do I search across related models in Filament global search?

In getGloballySearchableAttributes(), use dot notation in the returned array, ['title', 'author.name', 'category.name']. Filament will join the relation and apply a LIKE filter against the named column. Eager-load the same relations inside getGlobalSearchEloquentQuery() with parent::getGlobalSearchEloquentQuery()->with(['author', 'category']) so you don't trigger an N+1 every time results are rendered.

How do I scope global search to a specific tenant in Filament v4?

Override getGlobalSearchEloquentQuery() on the resource and apply the tenant constraint to the parent query, for example parent::getGlobalSearchEloquentQuery()->where('team_id', Filament::getTenant()?->getKey()). Do not add a global scope on the Eloquent model, that breaks queue workers, public-site queries, and seeders. The resource-level override is the right boundary because it only fires inside the admin panel.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.