Filament v3 Custom Table Columns: Rendering Complex UI Without Hacks
#filament #laravel #livewire #alpine

Filament v3 Custom Table Columns: Rendering Complex UI Without Hacks

3 min read Mohamed Said Mohamed Said

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 use evaluate() 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use Livewire actions inside a custom column view?
Not directly — column views are rendered per-row inside a Livewire component but are not themselves Livewire components. Use Alpine.js for client-side interactivity, or wire up a Filament table action triggered from a button inside the column view using `wire:click` pointing to the parent Livewire component's action.
Q02 How do I pass additional data from the record to the column view?
Add a method to your column class that calls `$this->getRecord()` or accepts a closure via `evaluate()`. Expose it as a public method and call `$getColumn()->yourMethod()` inside the Blade view. This keeps logic in PHP and the view purely presentational.
Q03 Does this approach work in Filament v4?
The class-based extension model is similar in v4, but v4 introduces the unified Schema API and some view variable names have changed. The patterns here are specific to v3. For v4, check the official upgrade guide before migrating custom columns.

Continue reading

More Articles

View all