mlsolutions/chartjs-widget
A Laravel dashboard widget that renders Chart.js charts from PHP configuration. Define datasets, labels, and options in your widget class, then display responsive, interactive charts in admin panels with minimal setup. Supports common chart types and easy customization.
composer require mlsolutions/chartjs-widget then immediately open vendor/mlsolutions/chartjs-widget/composer.json and confirm the laravel/nova and laravel/framework constraints match your install. With one star and zero dependents, this package is effectively unmaintained — pin an exact version in composer.json rather than relying on ^.Card subclass (look in src/ for the class extending Laravel\Nova\Card) and a Vue component in resources/js/. The component() return value and the array shape passed to withMeta() are the entire API surface — confirm both.cards() in app/Nova/NovaServiceProvider.php (default dashboard) or to a custom dashboard class in app/Nova/Dashboards/:// app/Nova/NovaServiceProvider.php
public function cards()
{
return [
(new \MlSolutions\ChartJsWidget\ChartJsCard)
->width('2/3'),
];
}
Trend metric. Nova's built-in metrics aggregate one column over a period; the moment you need two series on one axis (e.g. signups vs. cancellations) or a mixed line/bar chart, this widget is the escape hatch. Start by replacing an existing dashboard card that shows a single number with a bar chart of the last 30 days — it's the smallest change that proves the data pipeline end to end.If step 1 or 2 turns up a broken constraint (Nova 4 vs 5 asset registration is the usual culprit), abandon early: writing your own card with a Nova::script() asset and a hand-rolled Vue component is roughly a two-hour job and gives you a maintainable artifact.
One card class per chart, data injected via withMeta(). Don't subclass the package card with hardcoded queries inline across a dozen dashboards. Create a thin abstract base in app/Nova/Cards:
abstract class BaseChart extends \MlSolutions\ChartJsWidget\ChartJsCard
{
abstract protected function chartData(): array;
abstract protected function chartOptions(): array;
public function __construct()
{
parent::__construct();
$this->withMeta([
'chartData' => $this->chartData(),
'chartOptions' => $this->chartOptions(),
]);
}
}
Then each concrete card only owns a query. Everything in withMeta() is JSON-serialized into the card's payload, so keep it to arrays/scalars — no Carbon instances, no collections (cast with ->toArray() and format dates with ->toDateString()).
Cache the aggregate, not the card. Nova re-fetches cards on every dashboard poll and every filter change. A GROUP BY DATE(created_at) over a large table will hammer the DB:
protected function chartData(): array
{
$rows = Cache::remember('dash.signups.30d', now()->addMinutes(5), fn () =>
Order::query()
->where('created_at', '>=', now()->subDays(30))
->selectRaw('DATE(created_at) as d, COUNT(*) as total')
->groupBy('d')->orderBy('d')
->pluck('total', 'd')
);
return [
'labels' => $rows->keys()->all(),
'datasets' => [[
'label' => 'Orders',
'data' => $rows->values()->map(fn ($v) => (int) $v)->all(),
'backgroundColor' => 'rgba(54, 162, 235, 0.5)',
]],
];
}
Cast the values — MySQL COUNT() and SUM() come back as strings through PDO, and Chart.js will silently plot nothing useful if handed "12" in some option paths.
Wire it to Nova's filter bar. If the card should respect dashboard date filters, call ->refreshWhenFiltersChange() and read $_GET/request filters inside your query — the widget itself won't do it for you. Same for ->refreshWhenActionRuns() if actions mutate the underlying table.
Layout deliberately. ->width('1/3'|'1/2'|'2/3'|'full') is the only sane way to compose a dashboard. Time series want full; counters and partitions want 1/3. Set an explicit height via meta if the package supports it, otherwise the Vue component's default will collide with sibling cards.
Testing. Cards are plain PHP objects — assert the serialized payload without booting a browser:
$payload = (new OrdersChart)->jsonSerialize();
$this->assertCount(30, $payload['chartData']['labels']);
That single test catches the majority of real regressions (empty datasets, string values, timezone drift) far faster than eyeballing the dashboard.
this.chart.destroy() in beforeDestroy, you'll accumulate Chart.js instances and see memory climb plus ghost tooltips after ~30 minutes on a wall-mounted dashboard. Patch the compiled component or override it — this is the single most likely bug you'll hit.Trend/Partition metrics. If this widget also registers window.Chart, you can get a version clash where the newest registration wins and breaks Nova's native metrics. Check NovaServiceProvider::boot() for what the package registers and consider removing its script if Nova's global is already present and compatible.#333 grid/tick colors vanish in Nova's dark theme. Read document.documentElement.classList.contains('dark') in the Vue component, or pass theme-aware colors down through chartOptions. Re-render on theme toggle if you care — most people don't, and their charts are invisible half the time.DATE(created_at) groups server-side in the DB session timezone; Nova's own metrics group in config('app.timezone'). A dashboard showing 23 bars next to a Trend showing 30 days is a classic. Normalize in SQL with CONVERT_TZ or group in PHP after fetching, and be consistent everywhere.Nova::script(), the card will render an empty box with a console 404 for the JS bundle. Check resources/js compilation and whether you need to publish assets manually (php artisan vendor:publish). This is the main compatibility cliff between release dates.component(), withMeta(), width(), and the Vue template. There's no plugin system, no option builder. Treat it as a starting point you copy into app/Nova/Cards rather than a dependency you build on — this both insulates you from abandonment and makes the code reviewable.meta payload size. Putting 5,000 raw rows into withMeta() bloats the /nova-api/dashboards response and slows the initial dashboard paint. Always aggregate server-side; the client should receive pre-bucketed series, never raw records.component() and withMeta(). Register your own compiled asset in NovaServiceProvider::boot(), and you own the Chart.js version, the theme handling, and the refresh lifecycle permanently. For a package with 1 star, that trade is usually worth making on day one.How can I help you explore Laravel packages today?