Search

Custom Table Columns in Filament: A Complete Guide

Custom Table Columns in Filament: A Complete Guide

Key takeaways

  • ✓ Customise before you create. TextColumn with badge(), formatStateUsing(), description(), icon() and state() covers most “custom column” requests.
  • ✓ ViewColumn::make()->view() is the quick option for one-off markup. php artisan make:filament-table-column gives you a reusable, configurable class.
  • ✓ Custom columns render once per row. Never run queries inside the column view. Eager load, or compute state with aggregates.
  • ✓ Sorting and searching on computed values need explicit sortable(query: ...) and searchable(query: ...) callbacks.

Tables are the heart of most Filament panels, and sooner or later someone asks for a column that doesn’t exist: a progress bar, a sparkline, an avatar stack with a “+3”, or a status pill that changes colour based on a due date. This guide goes from the cheapest option to the most powerful, then shows how to build a reusable ProgressColumn class properly, with configuration, sorting and tests.

Also Read: Filament vs Building Your Own Admin Panel: When Is It Worth It?

Level 0: You probably don’t need a custom column

Filament’s built-in columns are very flexible. Before you create a class, check whether one of these does the job:

use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;

TextColumn::make('status')
    ->badge()
    ->color(fn (string $state): string => match ($state) {
        'paid' => 'success',
        'overdue' => 'danger',
        default => 'gray',
    })
    ->icon(fn (string $state) => $state === 'overdue' ? Heroicon::ExclamationTriangle : null),

TextColumn::make('customer.name')         // relationship, eager loaded automatically
    ->description(fn (Invoice $record) => $record->customer->email)
    ->searchable(),

TextColumn::make('days_overdue')
    ->state(fn (Invoice $record): int => max(0, (int) $record->due_at->diffInDays(now(), false)))
    ->suffix(' days')
    ->color(fn (int $state) => $state > 30 ? 'danger' : 'warning'),

TextColumn::make('orders_count')->counts('orders')->label('Orders'),
TextColumn::make('orders_sum_total')->sum('orders', 'total')->money('GBP'),

With enums that implement HasLabel, HasColor and HasIcon, a bare TextColumn::make('status')->badge() picks up the label, colour and icon automatically. That’s often the tidiest solution of all.

Level 1: ViewColumn for one-off markup

If you need custom HTML in one table, point a ViewColumn at a Blade file:

use Filament\Tables\Columns\ViewColumn;

ViewColumn::make('progress')->view('filament.tables.columns.progress-bar')
{{-- resources/views/filament/tables/columns/progress-bar.blade.php --}}
<div >
  <div >
    <div ></div>
  </div>
</div>

$getState() and $record are available in the view. This is fine for a single use. Once the second table needs it with a different colour, promote it to a class.

Also Read: Laravel: Best Filament Themes

Level 2: A reusable custom column class

php artisan make:filament-table-column ProgressColumn

This generates app/Filament/Tables/Columns/ProgressColumn.php extending Filament\Tables\Columns\Column, plus a view. As with fields, the docs warn that columns are not Livewire components. Data reaches the view through public getter methods, which are exposed as $getX().

namespace App\Filament\Tables\Columns;

use Closure;
use Filament\Tables\Columns\Column;

class ProgressColumn extends Column
{
    protected string $view = 'filament.tables.columns.progress-column';

    protected int | float | Closure $max = 100;

    protected string | Closure | null $barColor = null;

    protected bool | Closure $showsLabel = true;

    public function max(int | float | Closure $max): static
    {
        $this->max = $max;

        return $this;
    }

    public function barColor(string | Closure | null $color): static
    {
        $this->barColor = $color;

        return $this;
    }

    public function hideLabel(bool | Closure $condition = true): static
    {
        $this->showsLabel = fn (): bool => ! $this->evaluate($condition);

        return $this;
    }

    public function getMax(): float
    {
        return (float) $this->evaluate($this->max) ?: 100;
    }

    public function getPercentage(): int
    {
        $value = (float) $this->getState();

        return (int) round(min(100, max(0, $value / $this->getMax() * 100)));
    }

    public function getBarColor(): string
    {
        $color = $this->evaluate($this->barColor, [
            'percentage' => $this->getPercentage(),
        ]);

        return $color ?? match (true) {
            $this->getPercentage() >= 100 => 'success',
            $this->getPercentage() >= 50 => 'primary',
            default => 'warning',
        };
    }

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

Two techniques make this column feel native:

  1. Every option accepts a Closure and is resolved with evaluate(). Users can then write ->max(fn (Project $record) => $record->task_count) and get Filament’s usual utility injection ($record, $state, $livewire and so on).
  2. Passing extra named arguments to evaluate(). Above, barColor() closures can ask for a $percentage parameter that Filament wouldn’t normally provide.

The view:

{{-- resources/views/filament/tables/columns/progress-column.blade.php --}}
@php
  $percentage = $getPercentage();
  $color = $getBarColor();
@endphp

<div >
  <div
    
    role="progressbar"
    aria-valuenow="{{ $percentage }}"
    aria-valuemin="0"
    aria-valuemax="100"
  >
    <div
      @class([
        'h-full rounded-full transition-all',
        'bg-success-500' => $color === 'success',
        'bg-primary-500' => $color === 'primary',
        'bg-warning-500' => $color === 'warning',
        'bg-danger-500' => $color === 'danger',
      ])
      
    ></div>
  </div>

  @if ($showsLabel())
    <span >
      {{ $percentage }}%
    </span>
  @endif
</div>

Filament’s colour utilities (bg-primary-500 and so on) follow your panel’s colour configuration, so the column matches your theme automatically. If your custom classes don’t show up, remember that since v4 you need a custom theme with an @source entry for your views (see theming Filament).

Using it:

use App\Filament\Tables\Columns\ProgressColumn;

ProgressColumn::make('completed_tasks_count')
    ->label('Progress')
    ->counts('completedTasks')                          // aggregate, no N+1
    ->max(fn (Project $record) => $record->tasks_count ?: 1)
    ->barColor(fn (int $percentage) => $percentage < 25 ? 'danger' : null)
    ->sortable(),

For max() to read $record->tasks_count without a query per row, load the count on the table’s query: ->modifyQueryUsing(fn ($query) => $query->withCount('tasks')).

Also Read: Laravel: Filament Blueprint Review

Sorting and searching computed columns

->sortable() and ->searchable() work automatically when the column name is a real database column. For computed state, tell Filament how to do it in SQL:

ProgressColumn::make('progress')
    ->state(fn (Project $record) => $record->completed_tasks_count)
    ->max(fn (Project $record) => $record->tasks_count ?: 1)
    ->sortable(query: fn (Builder $query, string $direction) => $query
        ->withCount(['tasks', 'completedTasks'])
        ->orderByRaw('completed_tasks_count / NULLIF(tasks_count, 0) ' . ($direction === 'asc' ? 'asc' : 'desc'))),

Custom search works the same way: ->searchable(query: fn (Builder $query, string $search) => $query->where(...)). Keep direction values on a whitelist, as above, rather than interpolating user input into raw SQL. Laravel 13’s stricter orderByRaw() types will also flag it in Larastan.

Summaries under the column

Columns can show aggregates in the table footer, and custom columns inherit that ability:

use Filament\Tables\Columns\Summarizers\Average;

ProgressColumn::make('completion_rate')
    ->summarize(Average::make()->label('Avg.')->formatStateUsing(fn ($state) => round($state) . '%')),

Performance rules for custom columns

Custom columns are the most common cause of the “my table got slow” support ticket. The view runs for every visible row, so with 50 rows per page, one query in the view becomes 50 queries.

  • No queries in the view or in getters. Use counts(), sum(), avg(), or eager loading through modifyQueryUsing().
  • No per-row HTTP calls. Pre-compute and store the value, or cache it.
  • Be careful with signed URLs for images. Since v4, non-local disks default to private visibility, and generating temporary URLs for every row adds up.
  • Keep markup light. Nested Blade components per cell add rendering time on large pages.

Our checklist of 8 fixes for slow Filament tables goes deeper.

Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work - Laravel

Test the column

use function Pest\Livewire\livewire;

it('shows project progress', function () {
    $project = Project::factory()
        ->has(Task::factory()->count(4))
        ->has(Task::factory()->completed()->count(2), 'completedTasks')
        ->create();

    livewire(ListProjects::class)
        ->assertCanSeeTableRecords([$project])
        ->assertTableColumnExists('completed_tasks_count')
        ->assertSee('50%');
});

assertTableColumnStateSet() and assertTableColumnFormattedStateSet() are also available for checking exact values. See the testing tables docs and our Pest guide.

The same idea in infolists

The infolist equivalent is a custom entry (php artisan make:filament-infolist-entry), which works the same way: a class extending Entry, a view, and getter methods. If you build ProgressColumn, a matching ProgressEntry takes about ten minutes more and keeps list and detail pages consistent.

Also Read: What Is Jev? TypeSafe AI's 'System One' Model Explained (Pricing, API & Use Cases)

FAQ

How do I create a custom column in Filament?

Run php artisan make:filament-table-column MyColumn. Add configuration methods to the generated class (accepting Closure values and resolving them with $this->evaluate()), then render $getState() and $record in its Blade view.

What’s the difference between ViewColumn and a custom column class?

ViewColumn renders a Blade view with no configuration API, which suits one-off use. A custom class gives you reusable, chainable configuration methods and closure support.

Can custom columns be sortable and searchable?

Yes. They work automatically for real database columns. For computed values, pass sortable(query: fn (Builder $query, string $direction) => ...) and searchable(query: fn (Builder $query, string $search) => ...).

Why is my custom column making my table slow?

The view or its getters probably run a query or an expensive calculation for every row. Move the work into the table query with aggregates (counts(), sum()) or eager loading.

Sources and further reading

Related on The Web Tier: Custom Filament form fields

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy