Build a Stats Overview Widget with Trend Sparklines in Filament v4

Build a Filament stats overview widget with delta-driven colours, real-data sparklines, and live polling — the way you'd actually ship it, not the docs demo.

Steven Richardson
Steven Richardson
· 6 min read

Every admin panel ends up needing the same thing: a row of cards across the top showing the numbers that matter — revenue today, new signups, churn. Filament ships exactly that as the StatsOverviewWidget, but the docs example stops at a hardcoded string and a static sparkline. The interesting part — comparing this period to the last, colouring the card red when the metric drops, and drawing the sparkline from real rows — is left to you. This is the filament stats overview widget built the way you'd actually ship it.

If you're standing up the whole panel rather than one widget, my guide to a production-ready Filament dashboard covers the surrounding setup. Here I'm zooming into the KPI strip.

Generate the stats overview widget#

Filament has a dedicated generator flag for this widget type. Run it from your project root:

php artisan make:filament-widget RevenueOverview --stats-overview

That drops a class into your panel's widget directory — app/Filament/Widgets by default — extending StatsOverviewWidget. If the panel auto-discovers widgets (the default), the widget appears on the dashboard immediately with no manual registration. The whole thing is a Livewire component under the hood, which is what makes the live polling later essentially free.

Add stats, deltas, and description icons#

Stats live in the getStats() method, which returns an array of Stat objects. Each Stat::make() takes a label and a value; everything else is chained on. The description() and descriptionIcon() pair is what turns a bare number into a dashboard stats card with a sense of direction:

<?php

namespace App\Filament\Widgets;

use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class RevenueOverview extends StatsOverviewWidget
{
    protected function getStats(): array
    {
        return [
            Stat::make('Revenue (7 days)', '£12,400')
                ->description('8% increase')
                ->descriptionIcon('heroicon-m-arrow-trending-up')
                ->color('success'),

            Stat::make('New signups', '320')
                ->description('3% down on last week')
                ->descriptionIcon('heroicon-m-arrow-trending-down')
                ->color('danger'),

            Stat::make('Churn', '1.8%')
                ->description('Flat vs last week')
                ->descriptionIcon('heroicon-m-minus')
                ->color('gray'),
        ];
    }
}

Three cards, three colours. The descriptionIcon() argument is any registered icon name — the heroicon-m-* set ships with Filament, and the -m- prefix pulls the 20px "mini" variant that sits neatly on the description line. Hardcoded values are fine for a first render, but the colour and the arrow should be decided by the data, not by me typing 'success'.

Drive colour and the sparkline from real data#

Here's the pattern I reach for. Pull the current period and the previous period, compute the percentage change once, and let that single number decide the value, the description, the icon, and the colour:

use App\Models\Order;

protected function getStats(): array
{
    $thisWeek = Order::query()
        ->where('created_at', '>=', now()->subDays(7))
        ->sum('total');

    $lastWeek = Order::query()
        ->whereBetween('created_at', [now()->subDays(14), now()->subDays(7)])
        ->sum('total');

    // Guard against divide-by-zero on a brand new install.
    $delta = $lastWeek > 0
        ? round((($thisWeek - $lastWeek) / $lastWeek) * 100, 1)
        : 0;

    $up = $delta >= 0;

    return [
        Stat::make('Revenue (7 days)', '£' . number_format($thisWeek))
            ->description(abs($delta) . '% ' . ($up ? 'increase' : 'decrease'))
            ->descriptionIcon($up ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
            ->color($up ? 'success' : 'danger')
            ->chart($this->revenueChart()),
    ];
}

The sparkline is just an array of numbers passed to chart() — Filament renders them as a small inline stat trend chart, no Chart.js config required. Build it from the same table, one value per day, oldest first:

protected function revenueChart(): array
{
    return collect(range(6, 0)) // 6 days ago → today
        ->map(fn (int $daysAgo): float => Order::query()
            ->whereDate('created_at', now()->subDays($daysAgo))
            ->sum('total'))
        ->all();
}

That's seven points for a seven-day spark. The sparkline colour follows the stat's ->color() by default, so a red card gets a red line. If you want the line to disagree with the card — a green card with a muted grey spark, say — ->chartColor('gray') overrides it independently.

One thing to watch: those queries run unscoped. If your panel is multi-tenant, every Order query needs constraining to the current team or the numbers bleed across tenants — I work through that scoping in building a multi-tenant Filament team panel.

Poll for live updates#

Stat widgets refresh themselves with no JavaScript on your part. Filament reads a static $pollingInterval property on the widget and binds it to Livewire's wire:poll for you. Set it to an interval string to switch it on:

class RevenueOverview extends StatsOverviewWidget
{
    protected static ?string $pollingInterval = '10s';

    // getStats() ...
}

Set it to null to turn polling off entirely. There's a real cost hiding here, though: every poll re-runs getStats(), so my three period queries plus seven sparkline queries fire on every tick. At '10s' that's a lot of database traffic for numbers that barely move. Cache the expensive ones — the same approach I use to cache heavy queries behind Livewire computed properties — and let the cache TTL, not the poll interval, decide how fresh the data really is.

Stats overview widget vs. ChartWidget#

This is the confusion the docs never quite clear up. Both are dashboard widgets, but they answer different questions. StatsOverviewWidget is for headline numbers — a value, a delta, and at most a thumbnail sparkline. ChartWidget (and its LineChartWidget and BarChartWidget subclasses) is for an actual graph: axes, a legend, multiple datasets, the full Chart.js surface.

The rule I use: if the reader's eye should land on a single number, it's a stat; if they need to read a curve, it's a chart. The sparkline on a stat is deliberately not interactive — it's a glance, not a tool.

Gotchas and edge cases#

A few things that bite in practice.

The sparkline colour resolves through Filament's colour registry, not raw CSS. If ->chartColor() appears to do nothing, check you're passing a registered name — success, warning, a custom panel colour — rather than a hex string, which silently falls back.

A stats strip is most useful pinned above the table it summarises. On a resource List page you register it through getHeaderWidgets(), the same place you'd surface bulk CSV import and export actions — the cards summarise what the table lists.

By default the cards flow to fill the widget's width and the count adapts to the viewport. To make the whole strip span the full dashboard rather than a single grid column, set protected int | string | array $columnSpan = 'full'; on the widget class.

Finally, mind your empty states. A fresh install with no orders gives you a zero $lastWeek, which is why the divide-by-zero guard above matters — without it the first deploy throws on a dashboard nobody has data for yet.

Wrapping up#

Start with hardcoded Stat::make() cards to get the strip on the page, then wire each one to a period-over-period query so the colour and the arrow earn their place. Add a sparkline from a daily query, set $pollingInterval only if the numbers genuinely move, and cache those queries before you ship. From here the natural next step is the rest of the panel — search, filters, custom actions — and I walk through one of those in custom global search results in Filament.

FAQ#

How do I add a stats widget to a Filament dashboard?

Generate it with php artisan make:filament-widget RevenueOverview --stats-overview, which creates a class extending StatsOverviewWidget. Return your cards from the getStats() method as an array of Stat::make() objects. If your panel auto-discovers widgets — the default — it shows up on the dashboard with no further registration; otherwise add it to the panel's or page's widget list.

How do I show a trend chart in a Filament stat?

Chain ->chart([...]) onto the stat with an array of numbers, oldest value first. Filament renders that as a compact inline sparkline beneath the value, with no Chart.js setup needed. Populate the array from a query that returns one data point per period — daily revenue over the last seven days, for instance — so the line reflects real movement rather than placeholder data.

How do I color a Filament stat card based on a value?

Compute the change you care about first, typically this period versus the previous one, then pass the result into ->color(). A common pattern is ->color($delta >= 0 ? 'success' : 'danger'), paired with a matching ->descriptionIcon() so the arrow and the colour agree. Filament accepts its registered colour names: success, danger, warning, info, primary, and gray.

How do I make a Filament stat poll for live data?

Set the static $pollingInterval property on the widget class, for example protected static ?string $pollingInterval = '10s';. Filament binds it to Livewire's wire:poll, so the stats refresh on that interval with no JavaScript. Set it to null to disable polling. Because each poll re-runs every query in getStats(), cache the heavy ones so live updates don't hammer your database.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.