filament/widgets
Filament Widgets provides the widget system for Filament panels, letting you build dashboards with stats, charts, tables, and custom widgets. Configure dashboard layouts, poll for updates, and reuse widget components across admin panels and resources.
filament/widgets is a read-only subtree split of the monorepo at filamentphp/filament. "Dependents: 0" is expected — nobody installs it directly. Day-to-day you get it transitively:
composer require filament/filament # pulls filament/widgets, filament/panels, filament/support…
composer show filament/widgets # confirm the version matches your other filament/* packages
Only add filament/widgets to composer.json explicitly if you are pinning individual Filament packages to trim the dependency graph — and then you must pin every filament/* package to the same version constraint, or you will get cross-major class-not-found errors. The repo you linked is the right place to read source and file issues; the docs you actually want live at filamentphp.com/docs/{3.x|4.x}/widgets.
Prerequisites for anything below: Laravel 10/11/12, Livewire 3, Tailwind compiled through Filament's own pipeline (you do not add widget paths to your tailwind.config.js unless you are overriding views).
php artisan filament:install --panels, then create a user and log in at /admin.app/Providers/Filament/AdminPanelProvider.php. Note ->widgets([...]) and ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets'). Discovery is what you'll rely on 95% of the time — new classes in that folder appear on the dashboard with no registration step.php artisan make:filament-widget SalesOverview --stats-overview
php artisan make:filament-widget RevenueChart --chart
php artisan make:filament-widget LatestOrders --table
php artisan make:filament-widget Announcement --widget # plain, Blade-only
StatsOverviewWidget. That's the highest value-per-line change in the whole package — four numbers, four colours, a description and a sparkline in ~30 lines of PHP, no JS.namespace App\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class SalesOverview extends StatsOverviewWidget
{
protected static ?string $pollingInterval = '30s';
protected function getStats(): array
{
return [
Stat::make('Open orders', number_format($open = Order::where('status', 'open')->count()))
->description('Awaiting fulfilment')
->descriptionIcon('heroicon-m-clock')
->color($open > 50 ? 'danger' : 'success')
->chart(Order::where('status', 'open')->pluck('count_7d')->all()),
Stat::make('Revenue (30d)', '$' . number_format($revenue / 100, 2))
->description('+12% vs previous')
->descriptionIcon('heroicon-m-arrow-trending-up'),
];
}
}
class Announcement extends Widget
{
protected static string $view = 'filament.widgets.announcement';
protected int | string | array $columnSpan = 'full';
protected static ?int $sort = -10;
}
The Blade file wraps itself in the package's own component — not x-filament::widget:
<x-filament-widgets::widget>
<x-filament::section>
{{ $this->message }}
</x-filament::section>
</x-filament-widgets::widget>
| Need | Base class | You implement |
|---|---|---|
| KPI cards with deltas/sparklines | StatsOverviewWidget |
getStats(): array |
| One Chart.js chart | ChartWidget |
getData(), optionally getType(), getOptions() |
| Paginated/filterable list of records | TableWidget |
table(Table $table): Table |
| Anything else (feeds, iframes, markdown) | Widget + Blade |
$view |
AccountWidget and FilamentInfoWidget are the two shipped concretes; everything else in filament/widgets is infrastructure. Subclassing rather than composing is the intended extension point.
Three layers, pick the narrowest that works:
// 1. Whole panel (applies to every dashboard/user menu)
->widgets([AccountWidget::class, FilamentInfoWidget::class])
// 2. One dashboard page
class Dashboard extends Filament\Pages\Dashboard
{
public function getWidgets(): array
{
return [
...parent::getWidgets(),
SalesOverview::class,
RevenueChart::make()->columns(2), // WidgetConfiguration
];
}
public function getColumns(): int | array
{
return ['default' => 1, 'md' => 2, 'xl' => 4];
}
}
// 3. Widget itself (static defaults, overridable per instance)
protected static ?int $sort = 20;
protected int | string | array $columnSpan = ['default' => 'full', 'xl' => 1];
->columns() on WidgetConfiguration is the per-instance override of getColumnSpan(); use it when the same widget appears in two layouts. Ties in $sort are ordered by discovery order — don't leave gaps, they will reorder unpredictably when you add files.
Add HasFilters to your Dashboard page and a Filter form schema, then let each widget opt in:
use Filament\Widgets\Concerns\InteractsWithPageFilters;
class RevenueChart extends ChartWidget
{
use InteractsWithPageFilters;
protected static ?string $heading = 'Revenue';
protected function getData(): array
{
$start = $this->filters['startDate'] ?? now()->subMonth();
return [
'datasets' => [[
'label' => 'Revenue',
'data' => Order::where('created_at', '>=', $start)
->groupByMonth()->pluck('total')->all(),
'backgroundColor' => '#0ea5e9',
]],
'labels' => ['Jan', 'Feb', /* … */],
];
}
}
This is the single most common interaction pattern in real apps: filters live on the page, widgets read them via the trait, no event bus needed.
Widgets are Livewire components, so <livewire:app.filament.widgets.sales-overview /> works in any Blade view, including a plain controller-rendered page. It does require Filament's layout/asset pipeline for styling — in practice add the panel middleware stack (Filament\Http\Middleware\...) or at least the @filamentStyles/@filamentScripts directives, otherwise the fi-* classes render unstyled. Don't fight this; if you need widgets outside a panel, mount them in a Livewire page that uses Filament's layout.
protected static bool $isLazy = false; to opt out). Keep it on: the dashboard HTML flushes, then each widget makes its own request. It converts one slow TTFB into a fast one plus N small requests.protected static ?string $pollingInterval = '30s'. Leave null unless a human is actually staring at it.Cache::remember("stats.{$tenantId}", 300, fn () => ...) inside getStats()/getData() — the widget re-renders cheaply and polling stops hammering MySQL.getData() must be JSON-serialisable and use sequential arrays for labels and each datasets.*.data. Associative arrays silently produce a broken chart with no exception.Custom base widget for a house style:
abstract class BaseWidget extends Widget
{
protected int | string | array $columnSpan = ['default' => 1, 'xl' => 2];
public function getHeading(): ?string
{
return static::$heading ?? Str::headline(class_basename(static::class));
}
}
Permissions belong in canView() — Filament honours it before rendering and before lazy-loading, so the query never runs:
public static function canView(): bool
{
return auth()->user()->can('view_sales');
}
Overriding mount() on ChartWidget/TableWidget breaks things. Both base classes initialise state ($filters, table setup, chart id) in mount(). Always parent::mount() first, or you'll get a blank chart / "table is not configured" with no error.
getColumnSpan() wins over int $columnSpan. In newer versions the property is int | string | array; in older 3.x it was int | string. If you're publishing a widget across minor versions, define the method getColumnSpan() instead of the property — it survives both.
filament:cache-components / filament:optimize and widget discovery are enemies. After creating or renaming a widget class, a cached production build will still show the old list. Run php artisan filament:clear-cached-components (v3.2+) or filament:optimize-clear (v4). Same trap applies in CI: cache in the deploy step, clear in the build step.
Wrong view component = unstyled widget with zero errors. <x-filament::widget> renders an empty shell that swallows your slot content on some versions; the correct tag is <x-filament-widgets::widget>. If a custom widget renders nothing at all, check this first — it fails silently.
Polling intervals multiply. Ten widgets at '5s' is 120 Livewire requests/minute per user. Put pollingInterval only on the one or two widgets that need it, and stagger values ('10s', '30s', '60s') so they don't phase-lock.
Chart JS is bundled but your data isn't safe. Money as $1,234.56 inside ChartWidget data is fine as a label; as a dataset value it breaks the axis. Format in getOptions()/getData() with ->tooltipCallback-style options, keep raw numerics in data — or use Chart.js's ticks.callback in getOptions().
Stat::chart() needs a pre-computed array, not a query. It's passed straight through to a sparkline; it runs no aggregation. If you hand it 10 000 rows you get a 10 000-point SVG that freezes the browser. ->chart(collect($points)->take(-30)->values()->all()).
Livewire keys collide when you render the same widget twice. Two SalesOverview instances on one page share a Livewire id unless you pass a key: @livewire(SalesOverview::class, key('sales-overview-header')). Symptom is one widget updating twice and the other never.
InteractsWithPageFilters is silent outside a Dashboard. If the widget is dropped into a custom page without HasFilters, $this->filters is [] and every widget silently shows the default range. Guard with $this->filters['startDate'] ?? now()->subMonth() rather than assuming the key exists — that pattern also makes the widget reusable and testable.
Tailwind purge misses classes built at runtime. ->color($condition ? 'danger' : 'success') is fine (Stat maps those to compiled classes), but "text-{$colour}-500" in your own Blade is purged. Use Filament's semantic helpers (->color(), x-filament::badge) or safelist explicitly.
Testing a widget is just testing a Livewire component — no special harness needed, which is the strongest argument for the package:
it('shows open order count', function () {
Order::factory()->count(3)->create(['status' => 'open']);
livewire(SalesOverview::class)
->assertSuccessful()
->assertSee('Open orders')
->assertSee('3');
});
Add ->call('$refresh') between data mutations to assert polling behaviour, and assert canView() separately with actingAs() — permission bugs are otherwise invisible until a customer sees another tenant's revenue.
Debugging checklist when a widget vanishes (in order of frequency): not in a discovered path / wrong namespace in discoverWidgets() → component cache stale → canView() false → $view typo → exception swallowed inside getData() (check storage/logs/laravel.log; Livewire surfaces widget exceptions as a generic "component error" block in the UI). php artisan filament:about prints panel, discovery and cache state in one screen — run it before you go hunting.
Version drift across filament/* packages is the number-one upgrade failure mode with subtree-split installs. Before every upgrade: composer outdated 'filament/*' and bump them as a set. Mixing a v3 filament/widgets with a v4 filament/panels produces Call to undefined method inside Widget that looks like your bug but isn't.
How can I help you explore Laravel packages today?