happenv-com/filament-enhanced-charts
Enhance your Filament admin panels with flexible chart widgets for Laravel. happenv-com/filament-enhanced-charts adds customizable visualizations to build richer dashboards faster, with simple integration into existing resources and pages.
Install and register
composer require happenv-com/filament-enhanced-charts
php artisan vendor:publish --tag="filament-enhanced-charts-config"
php artisan filament:assets # only if the package ships compiled CSS/JS
If the package exposes a Filament plugin object, register it on the panel you actually use charts in — not in a service provider — so multi-panel apps don't load chart assets on panels that never render widgets:
public function panel(Panel $panel): Panel
{
return $panel
->plugin(\Happenv\FilamentEnhancedCharts\FilamentEnhancedChartsPlugin::make());
}
Where to look first
ChartWidget methods it overrides (getData(), getOptions(), getType(), getHeading()), and knowing the exact signatures saves you from fighting the base class.resources/views/vendor/... and tweak.config/filament-enhanced-charts.php — chart defaults (type, palette, height, polling) are usually set here and inherited by every widget, so overriding per-widget should be the exception.First use case
The fastest meaningful win is a KPI-plus-trend dashboard: generate a normal Filament chart widget, swap the parent class for the package's enhanced widget, and point getData() at a real aggregate.
php artisan make:filament-widget OrdersChart --chart
class OrdersChart extends EnhancedChartWidget
{
protected static ?string $heading = 'Orders';
protected static ?string $headingIcon = 'heroicon-o-shopping-cart'; // v1.2.0+
protected static ?string $maxHeight = '260px';
protected ?string $pollingInterval = '30s';
protected function getType(): string
{
return 'line';
}
protected function getData(): array
{
$rows = Order::query()
->where('created_at', '>=', now()->subDays(30))
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->groupBy('day')
->orderBy('day')
->pluck('total', 'day');
return [
'datasets' => [[
'label' => 'Orders',
'data' => $rows->values(),
'borderColor' => 'rgb(99, 102, 241)',
'fill' => false,
]],
'labels' => $rows->keys(),
];
}
}
That gets a working, polling, theme-consistent chart in one file — which is the right bar for "day-to-day leverage."
Extend the package widget, don't decorate Filament's. Inheritance is the intended path: you inherit heading/icon/height/palette defaults and every enhancement, and you only override getType() and getData(). Using the base Filament widget plus traits loses the header features and any option merging the package does.
The after-header slot (v1.2.0) is for widget-scoped controls. Its natural job is putting things next to the title that belong to that chart: a range selector, a "last updated" timestamp, an export CSV action, a link to the full report.
protected function getAfterHeaderSlot(): HtmlString
{
return new HtmlString(
view('filament.widgets.partials.chart-actions', [
'range' => $this->filter,
])->render()
);
}
Keep this content cheap. It renders on every Livewire round-trip for that widget — including each polling tick — so an N+1 query inside the slot defeats the point of a 30-second poll. Prefer static markup, already-loaded model attributes, or a cached value.
Pair the heading icon with the chart's semantic role, not decoration. The icon is the cheapest way to make a wall of widgets scannable: cart for revenue, users for signups, exclamation-triangle for error rate. Set it once via the static property so the list is readable at a glance in the widget class itself.
Drive variants with getFilters() rather than duplicating widgets. If you need "revenue by day / week / month," one widget with a filter form beats three near-identical classes. Remember to fold $this->filter into the query and to reset any cached dataset when it changes:
protected function getFilters(): ?array
{
return ['7d' => 'Last 7 days', '30d' => 'Last 30 days', '90d' => 'Last 90 days'];
}
Aggregate in SQL, format in PHP only for labels. groupBy/selectRaw plus pluck() keeps getData() returning small arrays. Casting created_at to a Carbon day bucket in PHP means pulling every row into memory — fine on seed data, a production incident on a real table.
Polling is a product decision, not a default. $pollingInterval on a dashboard the whole team leaves open all day is a steady request stream per user. Prefer a moderate interval (30s, 60s) or no polling plus an explicit refresh action; the after-header slot is a good home for that refresh control.
Reuse the header partial across widget types. Widgets that aren't the package's chart class (stats overviews, custom widgets) can include the same vendor partial to get a visually consistent heading row — a small consistency win that's hard to get otherwise in Filament dashboards.
Test widget rendering with Livewire, not HTTP. Livewire::test(OrdersChart::class)->assertSee('Orders') catches the common failures (bad data shape, exception in getData(), slot rendering error) far earlier than eyeballing the dashboard.
Re-publish views when upgrading. v1.2.0 changes the widget header view to support the icon and slot. If you previously published that partial, the icon/slot properties may silently do nothing until you diff your copy against the new vendor version. Same applies if you only published the config: check the changelog for new config keys.
getData() returning an empty datasets array breaks the JS chart. Guard the zero-rows case explicitly — return a single dataset with data => [] and a friendly label rather than letting the library receive null. This is the most common "chart is blank / console error" report.
Labels must be strings and data must be numeric. Collection pluck() results are often Collection objects or strings depending on the driver; ->values()->all() plus explicit casts prevents a chart that renders but plots nothing.
Dates as labels come back in whatever your DB driver produced. DATE() yields different strings on MySQL vs. SQLite, so widget tests that pass locally can fail in CI. Normalize (e.g. DATE_FORMAT, or format in the select raw) if you assert on label text.
Livewire re-renders and chart JS lifecycle. If your chart type/options change (filter switch, type toggle), the canvas needs re-initialization. Symptom: the old series stays visible or data doubles on each poll. Check the package's JS hook for whether it destroys/recreates; if not, key the canvas or force a full component refresh.
Dark mode and theme colors. Hardcoded hex colors in getOptions() look wrong on the dark palette and ignore the panel's primary color. Prefer Filament's CSS custom properties or the package's palette config, and verify both modes — chart packages rarely handle this for you fully.
filter is a public Livewire property. It's serialized and sent to the browser; don't stuff sensitive values in it, and validate it on the way in (a filter value used directly in a query is an injection vector if it isn't whitelisted — prefer matching against getFilters() keys).
Slots need HtmlString. Returning a plain string gets escaped and renders literal markup; returning an array won't render at all. If the slot is invisible, check the return type first.
Watch memory on wide date ranges. "Last 90 days" grouped by hour is 2,160 buckets — fine — but grouping by minute is not. Cap the granularity based on the selected range.
Dependency-bump-only releases are free upgrades. The bulk of v1.2.0's diff is Dependabot noise (trufflehog, prettier) plus CI/Moat config. Don't spend a review cycle on it. The one behavioral change is the widget header (icon + slot), so the only real regression surface is dashboards that overrode or republished the header view — smoke-test those specifically.
How can I help you explore Laravel packages today?