giacomomasseron/filament-async-column
Filament Async Column loads table column values asynchronously, so slow or expensive data (API calls, heavy queries, computed stats) no longer blocks your Filament tables from rendering. Cells show a placeholder and fill in when ready, keeping admin lists fast and responsive.
1. Confirm the gate before anything else. The package targets Filament v5. Check your composer.json:
composer show filament/filament | grep versions
If you're on v4 or below, stop — an unsupported major is a blocker, not a risk to manage.
2. Install.
composer require giacomomasseron/filament-async-column
There is no asset build step, no JS bundle, and no migration. The loaders are pure CSS, so npm run build is not part of the install.
3. Pick one genuinely slow column. Do not start with a column that resolves in 5ms — a loader that flashes is worse than no loader. Good first candidates:
4. Convert a single column. Minimal working example:
use GiacomoMasseroni\AsyncColumn\Column\AsyncColumn;
use Filament\Tables\Columns\TextColumn;
// before
TextColumn::make('shipment_eta'),
// after
AsyncColumn::make('shipment_eta')
->resolveUsing(fn ($record) => app(Carrier::class)->eta($record->tracking_number)),
Reload the table. The table should paint immediately and each cell should show the default skeleton loader, then the resolved value.
Where to look first:
AsyncColumn class itself — it's a small presentation-layer wrapper, and reading it end-to-end answers most questions the docs don't.Loader enum (GiacomoMasseroni\AsyncColumn\Enums\Loader) — skeleton, spinner, dots, bar.vendor/giacomomasseron/filament-async-column/.gitattributes / composer.json — confirms what ships at runtime, which matters if you plan to audit it.First use case to ship: one column, one table, loader() left at the default. Resist styling on day one; get the perf number first.
The package governs presentation while a value resolves. It does not cache, batch, queue, or throttle. That is entirely yours, and it belongs inside the closure:
AsyncColumn::make('credit_score')
->resolveUsing(fn ($record) => Cache::remember(
"credit-score:{$record->id}",
now()->addMinutes(15),
fn () => app(ScoreService::class)->for($record->email),
)),
Treat resolveUsing() as a cache read, not an API call. If you write the naive version first, you will ship an N+1 against a rate-limited third party.
The documented behavior covers the happy path only. Don't rely on the package to render a bounded error state — make the closure total:
->resolveUsing(function ($record) {
try {
return app(Carrier::class)->eta($record->tracking_number) ?? '—';
} catch (Throwable $e) {
report($e);
return '—';
}
})
Add an explicit timeout to the HTTP client behind the service so a hung call can't leave a cell spinning forever.
loader() and loaderColor() accept closures evaluated per row, which lets you turn loader styling into a documented convention rather than a per-table decision:
use GiacomoMasseroni\AsyncColumn\Enums\Loader;
AsyncColumn::make('shipment_eta')
->resolveUsing(fn ($record) => $this->carrier->eta($record))
->loader(Loader::Spinner) // or ->loader('spinner')
->loaderColor('primary'); // or a closure: fn ($record) => ...
A workable convention: skeleton (the default) for background enrichment, spinner for values a user is actively waiting on, and a severity tint for values that have a meaningful "late" state (amber for a stale ETA). Write it down in your team docs; otherwise every table drifts.
loader() and loaderColor() closures run per row and are styling decisions. Do not put I/O, cache() calls, or policy checks in them — put that logic in resolveUsing() and have the loader closure read from the already-resolved record.
If the same async column shape appears across resources, wrap it:
// app/Filament/Columns/CarrierEtaColumn.php
public static function make(string $name = 'shipment_eta'): AsyncColumn
{
return AsyncColumn::make($name)
->resolveUsing(fn ($record) => /* cached lookup */)
->loader(Loader::Spinner)
->loaderColor('primary');
}
This keeps the cache key, error handling, and loader convention in one place, and makes it trivial to swap back to TextColumn::make() if you ever drop the dependency.
Whatever you return from resolveUsing() is what the cell renders, so do the formatting there and return a string:
->resolveUsing(fn ($record) => $eta
? $eta->timezone($record->timezone)->format('D, M j · H:i')
: '—')
If you want badges, icons, or rich HTML in a cell, verify against your Filament version whether the column's usual formatters apply to the async-rendered value before designing around them — the safe fallback is a plain formatted string.
resolveUsing() and count invocations per table interaction (initial load, sort, filter, search, paginate, poll). This is the number that decides whether you need a longer cache TTL.loader() / loaderColor() on that one table.loadingState() silently beats loader(). Precedence is explicit in the release notes, and it's the right call for backward compatibility — but it means a table with existing custom loading markup will ignore your new spinner. Inventory every loadingState() usage before design specifies a loader that never renders.
The load problem is hidden, not solved. The loader presets change perceived performance, not call volume. A 25-row page against a rate-limited API is still 25 calls unless you cache. Instrument before you broaden adoption.
Re-render amplification is the big unknown. Whether resolveUsing() fires once per page load or on every Livewire re-render (sort, filter, search, pagination, poll) determines your real call multiplier — and it isn't documented. Measure it on a real table with a counter; assume it fires more than once until proven otherwise, and cache accordingly.
loaderColor() is an ordering dependency. Colors resolve through Filament's palette, including entries added via FilamentColor::register(). Panel color registration must happen before the table renders. Also verify light and dark mode for custom colors — low-contrast tints on a skeleton are the likely cosmetic defect, and it's easy to miss because the skeleton is faint by design.
Fast values should not get a loader. If the resolved value typically arrives in under ~100ms, a loader flashes and makes the table feel slower. Either leave those columns as a plain TextColumn with a synchronous value, or cache them warm so the async path returns instantly.
Async values aren't in the query. A column resolved client-side can't participate in database sorting, searching, or filtering the way a real column can. If a user must sort or filter by that value, it needs to be a persisted, indexed column — or you accept that the column is display-only. Check ->sortable() / ->searchable() behavior explicitly; don't assume it degrades gracefully.
Check exports. Filament's CSV/Excel export paths may not traverse an async resolver the same way the table cell does. If that column matters in exports, test it or exclude it deliberately.
Unregistered colors — verify the failure mode. It's unclear whether an unknown color fails loudly or falls back silently. Use a registered palette color (primary, warning, etc.) until you've confirmed, and don't ship a custom hex from a design spec without a smoke test.
Accessibility comes free — don't break it. All four presets stop animating under prefers-reduced-motion, and colors follow dark mode. If you customize via loadingState() for bespoke visuals, you own re-implementing both behaviors.
Escape hatch for bespoke visuals. The presets are deliberately pure CSS. If design wants branded illustrations or non-CSS assets, loadingState() is the supported extension point and is unchanged by v1.1.2 — but plan for the reduced-motion and dark-mode work yourself.
Dependency risk is real but bounded. Single maintainer, ~10 stars, zero dependents, MIT. The code surface is small, which caps the worst case. Pin to a tested minor, keep the one-afternoon fallback in mind (revert those columns to TextColumn::make() with a preloaded or synchronous value), and don't let the async column become load-bearing for a feature that can't survive a revert.
Debugging checklist when a cell stays on the skeleton:
resolveUsing() is actually firing — Log::info() at the top of the closure.loadingState() set.How can I help you explore Laravel packages today?