Search

Real-Time Dashboard Widgets in Filament

Real-Time Dashboard Widgets in Filament

Key takeaways

  • ✓ Stats and chart widgets poll by default (stats every 5 seconds). That’s fine for one user and expensive for fifty.
  • ✓ Choose the cheapest option that feels live enough: longer polling intervals, refreshing on events, or push over Laravel Reverb WebSockets.
  • ✓ Cache widget queries with Cache::flexible() so a busy dashboard doesn’t recompute the same aggregates on every poll.
  • ✓ Livewire 4 (Filament v5) makes polling non-blocking, but that doesn’t reduce server load.

A dashboard that updates itself feels alive. It also quietly generates a lot of HTTP requests. Filament makes “real-time” deceptively easy (widgets poll out of the box), so the real skill is choosing how live each number needs to be and paying as little as possible for it. This guide builds a dashboard with the three widget types, then compares polling, event-driven refresh and WebSocket push.

Also Read: Laravel and PHP

The three widget types

php artisan make:filament-widget RevenueStats --stats-overview
php artisan make:filament-widget OrdersChart --chart
php artisan make:filament-widget LatestOrders --table

(Run php artisan make:filament-widget RevenueStats without flags and the command asks which type you want.)

Stats overview

namespace App\Filament\Widgets;

use App\Models\Order;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Cache;

class RevenueStats extends StatsOverviewWidget
{
    protected ?string $pollingInterval = '30s';

    protected function getStats(): array
    {
        $stats = Cache::flexible('dashboard.revenue-stats', [20, 120], fn () => [
            'today' => Order::whereDate('created_at', today())->sum('total'),
            'yesterday' => Order::whereDate('created_at', today()->subDay())->sum('total'),
            'open' => Order::where('status', 'processing')->count(),
            'trend' => Order::selectRaw('DATE(created_at) d, SUM(total) t')
                ->where('created_at', '>=', now()->subDays(7))
                ->groupBy('d')->orderBy('d')->pluck('t')->all(),
        ]);

        $delta = $stats['yesterday'] > 0
            ? round(($stats['today'] - $stats['yesterday']) / $stats['yesterday'] * 100)
            : null;

        return [
            Stat::make('Revenue today', '£' . number_format($stats['today'], 2))
                ->description($delta === null ? 'No sales yesterday' : "{$delta}% vs yesterday")
                ->descriptionIcon($delta >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
                ->color($delta >= 0 ? 'success' : 'danger')
                ->chart($stats['trend']),

            Stat::make('Orders processing', $stats['open']),
        ];
    }
}

Two details:

  • In Filament v4 and v5, $pollingInterval is an instance property: protected ?string $pollingInterval = '30s';. In v3 it was static, and copying v3 snippets fails silently. Set it to null to turn polling off.
  • Cache::flexible() (Laravel 11+) is stale-while-revalidate. Values are fresh for 20 seconds, may be served stale for up to 120 seconds while a background refresh runs, and users never wait on the aggregate query.

Chart widget

use Filament\Widgets\ChartWidget;
use Flowframe\Trend\Trend;
use Flowframe\Trend\TrendValue;

class OrdersChart extends ChartWidget
{
    protected ?string $heading = 'Orders';

    protected ?string $pollingInterval = '60s';

    protected ?string $maxHeight = '280px';

    public ?string $filter = 'week';

    protected function getFilters(): ?array
    {
        return ['week' => 'Last 7 days', 'month' => 'Last 30 days', 'year' => 'This year'];
    }

    protected function getData(): array
    {
        $range = match ($this->filter) {
            'month' => [now()->subDays(30), 'perDay'],
            'year' => [now()->startOfYear(), 'perMonth'],
            default => [now()->subDays(7), 'perDay'],   // whitelist: $filter is user-controlled
        };

        $data = Trend::model(Order::class)
            ->between(start: $range[0], end: now())
            ->{$range[1]}()
            ->count();

        return [
            'datasets' => [[
                'label' => 'Orders',
                'data' => $data->map(fn (TrendValue $v) => $v->aggregate),
            ]],
            'labels' => $data->map(fn (TrendValue $v) => $v->date),
        ];
    }

    protected function getType(): string
    {
        return 'line';
    }
}

Filament’s charts use Chart.js, and the docs recommend flowframe/laravel-trend for time series. Note the match statement: the docs point out that $filter is user-controllable, so never pass it straight into a query.

Table widget

use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;

class LatestOrders extends TableWidget
{
    protected int | string | array $columnSpan = 'full';

    protected static ?int $sort = 3;

    public function table(Table $table): Table
    {
        return $table
            ->query(Order::query()->with('customer')->latest()->limit(10))
            ->columns([
                TextColumn::make('number'),
                TextColumn::make('customer.name'),
                TextColumn::make('total')->money('GBP'),
                TextColumn::make('created_at')->since(),
            ])
            ->paginated(false)
            ->poll('15s');
    }
}

Layout and loading

  • Order and width: protected static ?int $sort and protected int | string | array $columnSpan (a number, 'full', or a responsive array like ['md' => 2, 'xl' => 1]).
  • Grid: override getColumns() on a custom Dashboard page for 2, 3 or responsive columns.
  • Lazy loading: widgets load lazily by default, so the page shell appears first. Turn it off for above-the-fold KPIs with protected static bool $isLazy = false;.
  • Visibility: public static function canView(): bool hides finance widgets from support staff.

Option 1: Polling, and what it really costs

Polling is the default and the simplest option. Do the maths before shipping it, though:

Also Read: Laravel and PHP

8 widgets × 40 concurrent admins ÷ 5-second interval = 64 requests per second, each re-running widget queries.

That’s a meaningful load for an internal tool. Practical rules:

WidgetSensible interval
Revenue / signups today30–60s
Queue depth, active incidents5–15s
Monthly charts, cohortsnull (no polling)
“Latest X” tables15–30s

Livewire 4 (Filament v5) made wire:poll non-blocking, so a polling widget no longer delays a user’s click. That improves responsiveness, but the server still does the same work. Caching is what reduces the load.

Also Read: Best Filament Themes and PHP Starter Kits for 2026 (v5-Ready)

Option 2: Refresh on events instead of on a timer

Often “live” really means “update when something happens on this page”. Widgets are Livewire components, so they can listen for events:

use Livewire\Attributes\On;

class RevenueStats extends StatsOverviewWidget
{
    protected ?string $pollingInterval = null;   // no timer

    #[On('order-updated')]
    public function refreshStats(): void
    {
        Cache::forget('dashboard.revenue-stats');
        // Returning normally re-renders the widget
    }
}

Livewire events reach components on the same page, so this works for widgets shown on a resource page (above or below its table). Dispatch the event after an action on that page:

Action::make('markPaid')
    ->action(function (Order $record, $livewire) {
        $record->markPaid();
        $livewire->dispatch('order-updated');
    });

This costs nothing when nobody is doing anything. For changes made on other pages or by other users, use push (option 3).

Option 3: True push with Laravel Reverb

When changes come from outside the user’s session (webhooks, queue workers, other users), push them over WebSockets. Laravel Reverb is Laravel’s first-party WebSocket server.

php artisan install:broadcasting   # installs Reverb, Echo and config

Broadcast a domain event:

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class OrderPlaced implements ShouldBroadcast
{
    public function __construct(public Order $order) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('admin.orders')];
    }
}

Authorise the channel in routes/channels.php:

Broadcast::channel('admin.orders', fn (User $user) => $user->can('view_any_order'));

Listen in the widget. Livewire understands Echo event names:

#[On('echo-private:admin.orders,OrderPlaced')]
public function onOrderPlaced(): void
{
    Cache::forget('dashboard.revenue-stats');
}

For Echo to be available inside the panel, configure it the way Filament’s broadcast notifications docs describe (Filament loads Echo using the broadcasting.echo settings in config/filament.php). Once Echo is connected, you get database notifications pushed in real time as well, not polled.

Dashboard-wide filters

To let users pick a date range once and have every widget respect it, add a filters form to a custom dashboard:

namespace App\Filament\Pages;

use Filament\Forms\Components\DatePicker;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;

class Dashboard extends BaseDashboard
{
    use HasFiltersForm;

    public function filtersForm(Schema $schema): Schema
    {
        return $schema->components([
            Section::make()->schema([
                DatePicker::make('startDate'),
                DatePicker::make('endDate'),
            ])->columns(2),
        ]);
    }
}

Widgets read the filters with the InteractsWithPageFilters trait:

use Filament\Widgets\Concerns\InteractsWithPageFilters;

class RevenueStats extends StatsOverviewWidget
{
    use InteractsWithPageFilters;

    protected function getStats(): array
    {
        $start = $this->pageFilters['startDate'] ?? now()->startOfMonth();
        $end = $this->pageFilters['endDate'] ?? now();
        // include the dates in your cache key!
    }
}

Remember to include the filter values in cache keys, or every user will see the first user’s date range.

Also Read: Filament Blueprint Review (2026): Is PHP It Worth It for AI Agents?

Beyond stock widgets

FAQ

How do I make a Filament widget refresh automatically?

Set protected ?string $pollingInterval = '10s'; on stats or chart widgets, or call ->poll('10s') on a table widget’s table. Set the property to null to stop polling.

Why isn’t my pollingInterval working in Filament v4/v5?

It must be declared as an instance property (protected ?string $pollingInterval), not protected static as in Filament v3.

How do I push real-time updates to Filament widgets?

Broadcast an event with Laravel Reverb and Echo, then listen for it in the widget with #[On('echo-private:channel,EventName')]. The widget re-renders when the event arrives.

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

How do I stop widgets slowing down the dashboard?

Cache the aggregates (Cache::flexible()), use longer or no polling for slow-moving data, keep widgets lazy-loaded, and index the columns your aggregates filter on.

Sources and further reading

Related on The Web Tier: Why your Filament table is slow

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