Why Custom Columns Exist
Filament ships with TextColumn, BadgeColumn, ImageColumn, and a handful of others. They cover 80% of cases. The remaining 20% — sparklines, inline progress bars, multi-line compound cells, real-time status indicators — require you to either abuse TextColumn::make()->html() or build a proper custom column.
Abusing ->html() works until it doesn't: no Alpine state, no scoped CSS, no reusability, and a security surface you have to sanitise manually. Building a real column takes about 30 minutes and pays dividends across every resource that needs it.
Anatomy of a Custom Column
A Filament table column is a PHP class that extends Filament\Tables\Columns\Column and pairs with a Blade view. The framework calls ->render() on each column per row, passing a $state variable derived from the record.
// app/Tables/Columns/StatusBadgeColumn.php
namespace App\Tables\Columns;
use Filament\Tables\Columns\Column;
class StatusBadgeColumn extends Column
{
protected string $view = 'tables.columns.status-badge';
protected \Closure|string|null $colorCallback = null;
public function color(\Closure|string $color): static
{
$this->colorCallback = $color;
return $this;
}
public function getColor(): string
{
$state = $this->getState();
return $this->evaluate($this->colorCallback, [
'state' => $state,
'record' => $this->getRecord(),
]) ?? 'gray';
}
}
The evaluate() helper is inherited from Filament\Support\Concerns\EvaluatesClosures. It resolves both plain values and closures, injecting named parameters from the array you pass — exactly how core columns work internally.
The Blade View
{{-- resources/views/tables/columns/status-badge.blade.php --}}
@php
$color = $getColumn()->getColor();
$state = $getState();
@endphp
<div
x-data="{ tooltip: false }"
@mouseenter="tooltip = true"
@mouseleave="tooltip = false"
class="relative inline-flex"
>
<span class="px-2 py-0.5 rounded-full text-xs font-semibold
{{ match($color) {
'green' => 'bg-green-100 text-green-800',
'red' => 'bg-red-100 text-red-800',
'yellow' => 'bg-yellow-100 text-yellow-800',
default => 'bg-gray-100 text-gray-700',
} }}">
{{ $state }}
</span>
<div
x-show="tooltip"
class="absolute bottom-full mb-1 left-0 bg-black text-white text-xs rounded px-2 py-1 whitespace-nowrap"
>
Status: {{ $state }}
</div>
</div>
Filament injects $getColumn(), $getState(), $getRecord(), and $livewire into every column view automatically. You never need to pass them manually.
Registering and Using the Column
No service provider registration is needed. Import and use directly:
use App\Tables\Columns\StatusBadgeColumn;
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('name'),
StatusBadgeColumn::make('status')
->color(fn (string $state): string => match ($state) {
'active' => 'green',
'banned' => 'red',
'pending' => 'yellow',
default => 'gray',
}),
]);
}
Sortable and Searchable Support
Custom columns inherit ->sortable() and ->searchable() for free because those traits operate on the underlying database column name, not the view. If your column name maps 1:1 to a database column, they just work.
For computed or joined columns, pass an explicit sort callback:
StatusBadgeColumn::make('status')
->sortable(query: fn ($query, $direction) =>
$query->orderBy('status', $direction)
)
Testing the Column
it('renders the correct badge color for banned users', function () {
$user = User::factory()->create(['status' => 'banned']);
livewire(UserResource\Pages\ListUsers::class)
->assertCanSeeTableRecords([$user])
->assertTableColumnStateSet('status', 'banned', record: $user);
});
Filament's Pest helpers assert on state, not rendered HTML, which keeps tests resilient to styling changes.
Key Takeaways
- Extend
Column, declare$view, and useevaluate()for closure-or-value properties. - Blade views receive
$getColumn(),$getState(), and$getRecord()automatically. - Alpine.js works inside column views without any extra wiring.
->sortable()and->searchable()are inherited; override with callbacks for computed columns.- Test column state, not rendered markup, for durable assertions.