Filament v3 Custom Table Columns: Rendering, State, and Performance at Scale
#filament #laravel #tables #performance

Filament v3 Custom Table Columns: Rendering, State, and Performance at Scale

4 min read Mohamed Said Mohamed Said

Why Custom Columns Deserve More Attention

Filament's built-in columns cover 80% of cases, but the remaining 20% — composite data, computed badges, relationship-derived icons — quickly becomes a mess of ->html() hacks and anonymous closures scattered across your resource. A proper custom column class gives you a reusable, testable, IDE-friendly primitive.

This article focuses on the mechanics that matter in production: state resolution, eager loading declarations, and keeping Blade views lean.


Anatomy of a Custom Column

Every custom column extends Filament\Tables\Columns\Column. The minimum surface you need to understand:

  • getState() — resolves the column's value from the record
  • getExtraAttributes() — merges HTML attributes onto the cell
  • The Blade view referenced by $view
namespace App\Filament\Tables\Columns;

use Filament\Tables\Columns\Column;

class SubscriptionStatusColumn extends Column
{
    protected string $view = 'filament.tables.columns.subscription-status';

    public function getState(): mixed
    {
        $record = $this->getRecord();

        return [
            'label' => $record->subscription?->plan->name ?? 'Free',
            'active' => $record->subscription?->isActive() ?? false,
            'trial' => $record->subscription?->onTrial() ?? false,
        ];
    }
}

The Blade view receives $getState as a closure:

@php
    $state = $getState();
@endphp

<span @class([
    'px-2 py-0.5 rounded text-xs font-medium',
    'bg-green-100 text-green-800' => $state['active'] && !$state['trial'],
    'bg-yellow-100 text-yellow-800' => $state['trial'],
    'bg-gray-100 text-gray-500' => !$state['active'],
])>
    {{ $state['label'] }}
</span>

Declaring Eager Load Relationships

This is where most custom column implementations fall apart. If your getState() touches a relationship, every row triggers a lazy load. Filament provides ->relationship() on built-in columns, but for custom columns you must override getRelationships():

public function getRelationships(): array
{
    return ['subscription', 'subscription.plan'];
}

Filament's table builder calls getRelationships() on every column and merges the results into a single with() call before executing the query. Declare nested dot-notation paths exactly as you would in Eloquent.

If your column conditionally touches different relationships based on a configuration closure, resolve the closure inside getRelationships() before returning:

public function getRelationships(): array
{
    $extra = value($this->extraRelationship);

    return array_filter([
        'subscription',
        'subscription.plan',
        $extra,
    ]);
}

Fluent Configuration Methods

Custom columns should feel native. Add fluent setters using the Macroable-style pattern Filament itself uses — store values in $this->evaluate()-compatible closures so they support both static values and record-aware closures:

protected bool | Closure $showTrialBadge = true;

public function showTrialBadge(bool | Closure $show = true): static
{
    $this->showTrialBadge = $show;

    return $this;
}

public function isShowingTrialBadge(): bool
{
    return $this->evaluate($this->showTrialBadge);
}

Passing $this->evaluate() a closure automatically injects the current record, so callers can write:

SubscriptionStatusColumn::make('subscription_status')
    ->showTrialBadge(fn ($record) => $record->created_at->isAfter(now()->subDays(30)))

Keeping Views Fast

Blame slow tables on views that call PHP methods per cell. Rules:

  1. Resolve once — call $getState() once at the top of the view and destructure.
  2. No Eloquent in views — all relationship data must come through getState().
  3. Avoid @livewire inside column views — each cell is already inside a Livewire component; nesting adds wire overhead.
  4. Cache computed values in getState() if the column is used in sortable or searchable contexts where it may be called multiple times per request.

Registering the Column for Auto-Discovery

If you ship this inside a package or a shared module, register it in a service provider so teams can use it without imports:

use Filament\Support\Facades\FilamentAsset;

public function boot(): void
{
    FilamentAsset::register([
        // register any JS/CSS assets here if your column needs them
    ]);
}

For in-app columns, a simple use statement is sufficient — no registration needed.


Takeaways

  • Override getRelationships() to declare eager loads; skipping this causes N+1 at the column level.
  • Store configurable options as bool | Closure and resolve via $this->evaluate() for record-aware flexibility.
  • Keep Blade views dumb: resolve all state in getState(), destructure once at the top of the view.
  • Fluent setters make custom columns feel native and keep resource files readable.
  • Test getState() directly by instantiating the column, setting a mock record, and asserting the returned array.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why does my custom column still trigger N+1 queries even after overriding getRelationships()?
Check that the relationship names returned exactly match the Eloquent relation method names on your model, including nested dot-notation paths. A typo silently skips the eager load. Also verify you are not calling additional relationships inside the Blade view itself.
Q02 Can I make a custom column sortable or searchable?
Yes. Call ->sortable() or ->searchable() as usual, but provide a custom sort or search query callback when the column state is computed rather than a direct database column: ->sortable(query: fn ($query, $direction) => $query->orderBy('subscriptions.status', $direction)).
Q03 How do I write a Pest test for a custom column's getState() output?
Instantiate the column with ::make('name'), call ->record($model) to inject a model, then assert the return value of getState(). No Livewire test harness is needed for pure state logic — only bring in livewire() helpers when testing the rendered table interaction.

Continue reading

More Articles

View all