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 recordgetExtraAttributes()— 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:
- Resolve once — call
$getState()once at the top of the view and destructure. - No Eloquent in views — all relationship data must come through
getState(). - Avoid
@livewireinside column views — each cell is already inside a Livewire component; nesting adds wire overhead. - 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 | Closureand 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.