eightynine/filament-reports
Filament Reports brings report-building to Filament admin panels. Define report classes with query-based data, columns, filters, and actions, then give teams a dedicated page to view and export business data without building custom dashboards from scratch.
Install the package with Composer and register the plugin on the panel you want reports in. Match the package major to your Filament major — 4.x targets Filament v4, 3.x targets Filament v3:
# Filament v4 panel
composer require eightynine/filament-reports:^4.0
# Filament v3 panel
composer require eightynine/filament-reports:^3.1
php artisan vendor:publish --tag="filament-reports-config" # optional, but do it before you customize
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->plugin(\Eightynine\FilamentReports\FilamentReportsPlugin::make());
}
Where to look first:
config/filament-reports.php — it controls default pagination, export behavior, and the disk/format used for exported files. Skim it before writing any report.Report class shipped by the package — this is the contract every report extends. Read its abstract/stub methods; they tell you exactly what a report is made of (query, columns, filters).First use case — a "Revenue by month" report. Generate the skeleton with the package's artisan command (typically php artisan make:filament-report RevenueReport), then fill in the query and columns:
class RevenueReport extends Report
{
protected function query(): Builder
{
return Order::query()
->selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, SUM(total) as revenue, COUNT(*) as orders')
->groupBy('month')
->orderByDesc('month');
}
protected function columns(): array
{
return [
TextColumn::make('month')->label('Month')->sortable(),
TextColumn::make('revenue')->money('usd'),
TextColumn::make('orders')->numeric(),
];
}
}
Register it wherever your panel exposes pages (a Reports page or a widget), open the panel, and confirm the table renders before adding filters or exports. Get the "happy path" working first — filters and export actions are where most debugging time goes.
Report as a first-class class. Keep each report in its own class under app/Filament/Reports. Treat the query as the source of truth and the columns as presentation only. This makes reports reusable across a dedicated page, a dashboard widget, and a scheduled email job without duplication.
Query-first, columns-second. Build and test the raw query in Tinker or a feature test before wiring it into the report. The report layer is thin; almost every bug is a SQL bug (wrong join, missing groupBy, filter applied to the wrong table alias).
Filters as composable state. Define filters (date range, select, ternary) on the report class and let the package bind them into the query. A common pattern is a shared DateRangeFilter applied to created_at, with a SelectFilter on status:
protected function filters(): array
{
return [
Filter::make('period')->form([DatePicker::make('from'), DatePicker::make('until')])
->query(fn (Builder $q, array $data) => $q
->when($data['from'], fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('created_at', '<=', $d))),
SelectFilter::make('status')->options(OrderStatus::class),
];
}
Embedding three ways. Reports typically surface as (a) a dedicated Filament page in the nav, (b) a widget on a dashboard, or (c) a tab inside a resource page (e.g., a "Reports" tab on the Customer resource scoped to customer_id). Build the report so the query accepts a scope (tenant ID, customer ID) via a constructor or settable property — this is what makes one report serve all three contexts.
Exports as actions, not a separate feature. Wire CSV/Excel export into the report's header actions rather than building a separate controller. The package's export action already respects the current filter state, which is the behavior users expect ("export what I'm looking at"). If you need scheduled exports, reuse the same report class from a queued console command and ->toCsv() on the query — do not re-implement the column mapping.
Authorization. Gate report visibility with Filament's canView() / policies and, for multi-tenant panels, scope every query by tenant_id in the base query — filters must not be able to widen that scope.
Testing. Write one feature test per report that hits the query builder with known fixtures and asserts row counts and aggregate values. Asserting the rendered Filament table is brittle; asserting the query is not.
composer.json before upgrading either side: upgrading Filament to v4 on a filament-reports:^3.1 constraint will silently leave the package on a Filament v3-only major and break widget/page registration before it breaks your reports. Align majors: Filament v3 → package 3.x, Filament v4 → package 4.x.Report, filter/column contracts, plugin registration) shifted. Read the diff before dropping 4.0.0 into an existing panel that has custom reports or subclasses.groupBy and also filter on a column, MySQL's ONLY_FULL_GROUP_BY will reject the query. Always select every non-aggregated column you filter or order by, or use ->selectRaw() consistently.customers onto orders gives you two id and two created_at columns. Alias everything (orders.created_at as ordered_at) — Filament columns reference the alias, and a silent collision produces wrong values with no error.->with([...]) in the base query for any relationship-backed column; it is the single biggest performance win.dd($report->query()->toSql()) and run it manually. Nine times out of ten it's a filter that binds null into a where, or a DatePicker returning a string the query casts incorrectly. Filament's ->when() pattern (shown above) avoids most of this.whereDate uses the DB timezone while DatePicker uses the app timezone. For reports spanning midnight, store UTC and convert explicitly, or users will report "missing" rows from the first hours of the day.Report class. Custom column types are best added as your own Filament column classes rather than monkey-patching the package.^4.0, not *) and read the changelog diff before every minor bump.How can I help you explore Laravel packages today?