Filament monthly revenue chart with year filter
A Filament 4 line chart widget that totals paid invoices per month for a selectable year, filling empty months with zero.
FilamentChartsfilamentchartrevenuewidget
Code
<?php
namespace App\Filament\Widgets;
use App\Models\Payment;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\Cache;
class RevenueChart extends ChartWidget
{
protected ?string $heading = 'Revenue by month';
protected int|string|array $columnSpan = 'full';
protected ?string $maxHeight = '300px';
// Default filter value; the dropdown overrides $this->filter.
public ?string $filter = null;
protected function getFilters(): ?array
{
$years = range(now()->year, now()->year - 4);
return array_combine($years, $years);
}
protected function getData(): array
{
$year = (int) ($this->filter ?: now()->year);
// Cache briefly: the chart re-queries on every filter change and poll.
$totals = Cache::remember("widget.revenue.$year", 300, fn () => Payment::query()
->whereYear('paid_at', $year)
->selectRaw('month(paid_at) as month, sum(amount) as total')
->groupBy('month')
->pluck('total', 'month'));
// Always 12 points, so a quiet month shows as 0 instead of vanishing.
$series = collect(range(1, 12))->map(fn (int $m) => round(($totals[$m] ?? 0) / 100, 2));
return [
'datasets' => [[
'label' => "Revenue $year",
'data' => $series->all(),
'borderColor' => '#4f46e5',
'backgroundColor' => 'rgba(79, 70, 229, 0.12)',
'fill' => true,
'tension' => 0.3,
]],
'labels' => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'plugins' => ['legend' => ['display' => false]],
'scales' => ['y' => ['beginAtZero' => true]],
];
}
}
Needs: filament/filament@^4.0
How it works
What it solves
A revenue-per-month line is the first chart most billing dashboards need. This widget gives you one with a year dropdown, correct empty months and a cheap query, without any chart library setup, since Filament ships Chart.js.
How it works
getFilters() returns the last five years; Filament renders them as a dropdown and stores the choice in $this->filter. getData() reads that value, runs one grouped query that sums amount by calendar month, and plucks it into a month-number map.
The key step is range(1, 12): every month is mapped through the totals, defaulting to zero. Without it, a month with no payments is missing from the dataset and the labels no longer line up with the points. Amounts are stored in cents, so they are divided by 100 once at the end.
The result is cached for five minutes per year, because every filter change and every poll would otherwise hit the database again.
Adapting it
Change Payment and paid_at to your table, switch getType() to bar for columns, and add a second dataset (for example refunds) with its own colour. Set $heading to a translation key if your panel is multilingual.
Pitfalls
month()andwhereYear()are MySQL flavoured; on PostgreSQL useextract(month from paid_at).- Cache keys must include anything that scopes the data (tenant id), or customers will see each other's totals.
- The filter value arrives as a string, cast it before using it in arithmetic.
How to use it
- Copy the widget into
app/Filament/Widgets/; the admin panel discovers it automatically. - Replace
Payment,paid_atandamountwith your paid-transactions model (amount in cents). - If you scope data by tenant, add the tenant id to the query and the cache key.
- Open the dashboard and pick a year in the widget's dropdown.
- For other chart types change
getType()tobar,doughnutorpieand adjust the dataset.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.