Key takeaways
- ✓ Customise before you create.
TextColumnwithbadge(),formatStateUsing(),description(),icon()andstate()covers most “custom column” requests. - ✓
ViewColumn::make()->view()is the quick option for one-off markup.php artisan make:filament-table-columngives 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: ...)andsearchable(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 ProgressColumnThis 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:
- Every option accepts a
Closureand is resolved withevaluate(). Users can then write->max(fn (Project $record) => $record->task_count)and get Filament’s usual utility injection ($record,$state,$livewireand so on). - Passing extra named arguments to
evaluate(). Above,barColor()closures can ask for a$percentageparameter 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 throughmodifyQueryUsing(). - 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
- Filament custom columns
- Filament text column
- Filament table summaries
- Filament testing tables
- Laravel aggregate relationships (withCount)
Related on The Web Tier: Custom Filament form fields
