Laravel Magazine

Building Custom Dashboard Widgets in Filament v3

Eric Van Johnson ·
Building Custom Dashboard Widgets in Filament v3

Filament's dashboard is one of the most powerful parts of the panel builder. Out of the box it looks professional, and with a few custom widgets it can become genuinely useful for your users -- not just a placeholder for an admin panel.

In this tutorial, we'll build three types of custom widgets: a stats overview widget, a custom chart widget, and a table widget. We'll also cover how to control which widgets appear for which user roles.


Prerequisites

  • Laravel application with Filament v3 installed
  • A panel configured (typically via php artisan filament:install --panels)

The Three Widget Types

Filament v3 supports three built-in widget types that you can extend:

StatsOverviewWidget -- displays a grid of stat cards with values, trends, and descriptions. Great for KPI summaries.

ChartWidget -- renders a chart (line, bar, doughnut, etc.) using Chart.js. Good for time-series data.

TableWidget -- embeds a full Filament table on the dashboard. Useful for "recent activity" and "pending items" lists.

You can also build completely custom widgets using Livewire.


Part 1: Stats Overview Widget

Generate the widget:

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

This creates app/Filament/Widgets/StatsOverview.php. Fill it in:

<?php

namespace App\Filament\Widgets;

use App\Models\Order;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class StatsOverview extends BaseWidget
{
    protected function getStats(): array
    {
        return [
            Stat::make('Total Users', User::count())
                ->description('All registered accounts')
                ->descriptionIcon('heroicon-m-users')
                ->color('primary'),

            Stat::make('New Orders Today', Order::whereDate('created_at', today())->count())
                ->description('Orders placed in the last 24 hours')
                ->descriptionIcon('heroicon-m-shopping-cart')
                ->color('success'),

            Stat::make('Pending Orders', Order::where('status', 'pending')->count())
                ->description('Awaiting fulfillment')
                ->descriptionIcon('heroicon-m-clock')
                ->color('warning'),
        ];
    }
}

You can also add a trend chart to each stat using the chart() method, which accepts an array of values to render as a sparkline:

Stat::make('New Users This Week', User::whereBetween('created_at', [now()->startOfWeek(), now()])->count())
    ->chart(
        User::selectRaw('COUNT(*) as count, DATE(created_at) as date')
            ->whereDate('created_at', '>=', now()->subDays(7))
            ->groupBy('date')
            ->pluck('count')
            ->toArray()
    )
    ->color('success'),

Part 2: Custom Chart Widget

Generate the widget:

php artisan make:filament-widget OrdersChart --chart

Open app/Filament/Widgets/OrdersChart.php:

<?php

namespace App\Filament\Widgets;

use App\Models\Order;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;

class OrdersChart extends ChartWidget
{
    protected static ?string $heading = 'Orders Per Day (Last 30 Days)';

    protected static ?int $sort = 2;

    protected function getData(): array
    {
        $data = Order::selectRaw('COUNT(*) as count, DATE(created_at) as date')
            ->whereDate('created_at', '>=', now()->subDays(30))
            ->groupBy('date')
            ->orderBy('date')
            ->get();

        return [
            'datasets' => [
                [
                    'label' => 'Orders',
                    'data' => $data->pluck('count')->toArray(),
                    'borderColor' => '#3b82f6',
                    'backgroundColor' => 'rgba(59, 130, 246, 0.1)',
                    'fill' => true,
                ],
            ],
            'labels' => $data->pluck('date')
                ->map(fn ($date) => Carbon::parse($date)->format('M j'))
                ->toArray(),
        ];
    }

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

Supported chart types are: line, bar, doughnut, pie, polarArea, radar, and bubble. All are rendered via Chart.js, so any Chart.js-compatible dataset configuration will work.


Part 3: Table Widget (Recent Orders)

Generate the widget:

php artisan make:filament-widget LatestOrders --table

Open app/Filament/Widgets/LatestOrders.php:

<?php

namespace App\Filament\Widgets;

use App\Filament\Resources\OrderResource;
use App\Models\Order;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;

class LatestOrders extends BaseWidget
{
    protected static ?int $sort = 3;

    protected int | string | array $columnSpan = 'full';

    public function table(Table $table): Table
    {
        return $table
            ->query(
                Order::query()->latest()->limit(10)
            )
            ->columns([
                Tables\Columns\TextColumn::make('id')
                    ->label('Order #')
                    ->sortable(),
                Tables\Columns\TextColumn::make('user.name')
                    ->label('Customer')
                    ->searchable(),
                Tables\Columns\TextColumn::make('status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending'   => 'warning',
                        'completed' => 'success',
                        'cancelled' => 'danger',
                        default     => 'gray',
                    }),
                Tables\Columns\TextColumn::make('total')
                    ->money('USD')
                    ->sortable(),
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable(),
            ])
            ->actions([
                Tables\Actions\Action::make('view')
                    ->url(fn (Order $record) => OrderResource::getUrl('view', ['record' => $record])),
            ]);
    }
}

The $columnSpan = 'full' property makes this table stretch the full width of the dashboard.


Registering Widgets on the Dashboard

By default, Filament discovers widgets automatically. If you want explicit control, override the dashboard's getWidgets() method. Create a custom dashboard page:

php artisan make:filament-page Dashboard --panel=admin

In app/Filament/Pages/Dashboard.php:

<?php

namespace App\Filament\Pages;

use App\Filament\Widgets\LatestOrders;
use App\Filament\Widgets\OrdersChart;
use App\Filament\Widgets\StatsOverview;

class Dashboard extends \Filament\Pages\Dashboard
{
    public function getWidgets(): array
    {
        return [
            StatsOverview::class,
            OrdersChart::class,
            LatestOrders::class,
        ];
    }
}

Role-Based Widget Visibility

Different users should see different things. Filament makes this straightforward with the static canView() method on any widget:

// In StatsOverview.php
public static function canView(): bool
{
    return auth()->user()->hasRole('admin');
}

For more granular control, use policies or check specific permissions:

public static function canView(): bool
{
    return auth()->user()->can('view_sales_stats');
}

You can scope this per widget. Your admins see the full stats overview. Your support team sees only the latest orders table. Your managers see the charts. Each group gets a dashboard that's relevant to their actual work.


Controlling the Grid Layout

Widgets support responsive column spans. By default, the dashboard uses a 3-column grid. You can customize how wide each widget appears at different breakpoints:

protected int | string | array $columnSpan = [
    'default' => 'full',
    'sm' => 2,
    'md' => 2,
    'lg' => 1,
    'xl' => 1,
    '2xl' => 1,
];

The values correspond to the number of columns the widget occupies within the 12-column grid. 'full' spans all columns at that breakpoint. Numeric values like 1 take up one column.


Auto-Refreshing Widgets

For dashboards that need live data, you can enable polling on any widget:

protected static ?string $pollingInterval = '15s';

This triggers an automatic Livewire refresh every 15 seconds. Use it sparingly -- polling on multiple widgets simultaneously puts constant database load on your server. For truly real-time needs, consider combining widgets with Laravel Reverb broadcasting.


Wrapping Up

Filament's widget system strikes a good balance between "works out of the box" and "fully customizable." The three built-in widget types cover the vast majority of dashboard use cases, and when they don't, you can fall back to a raw Livewire component within the panel.

For the full widget documentation, see the official Filament docs.


Custom Livewire Widgets

When the three built-in widget types aren't enough -- say you need a Kanban board, a calendar heat map, or a live activity feed -- you can create a fully custom widget backed by a Livewire component.

php artisan make:filament-widget ActivityFeed

This generates a widget class and a Blade view at resources/views/filament/widgets/activity-feed.blade.php. The widget class extends \Filament\Widgets\Widget and you fill in the protected static string $view to point at your Blade view. From there it's a standard Livewire component -- add properties, actions, polling, whatever the feature needs.

This escape hatch is what makes Filament's dashboard genuinely flexible rather than just opinionated.


Sources:

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.