kirschbaum-development/nova-chartjs
Add interactive Chart.js charts to Laravel Nova dashboards with ready-made, configurable cards. Display responsive line, bar, pie, and other visualizations using simple PHP options alongside your Nova metrics.
The gate is Nova, not Laravel. This package is a Nova field — a PHP wrapper that serializes a Chart.js config into a payload a shipped Vue component renders. Everything else (Laravel version, PHP version, asset pipeline) is incidental. Release 2.0.0 (March 2023) added Nova 4 support and nothing has shipped since, so Nova 5 is unverified. Spend the first hour here, before any application code:
composer why-not kirschbaum-development/nova-chartjs
If that reports a conflict against your pinned Nova major, you are done — the decision is fork-or-skip, not workaround-or-skip.
Install, register nothing. There is no service provider, no config/ to publish, no migration, no npm install in your app. The Vue component and a prebuilt dist/ bundle are committed to the package, so composer require genuinely is the whole install. That is the package's main selling point and it holds up.
composer require kirschbaum-development/nova-chartjs
Read these four things, in this order, before writing a field:
README.md — the fluent surface is tiny (make(), chart type, data). You will learn more from the Chart.js docs than from this README, because the package mostly forwards your array.src/ — typically one field class plus a trait. Ten minutes, and from that point you understand the package better than its documentation does. You effectively own this code.resources/js/components/ — specifically for three questions: does it call chart.destroy() before re-init, what prop shape does it expect, and does it render labels into HTML or into canvas? The second and third answers determine your XSS posture and your debuggability.dist/ — note which Chart.js major is bundled. That version is frozen; nothing in your package.json or Dependabot config will ever bump it.First use case: one record-scoped chart on one detail view. Not a dashboard. The package description says "dashboards and cards," but the implementation is a detail-view field — Nova's native Value/Trend/Partition metrics already own the dashboard surface, and they do it better. Pick something like "monthly usage for this account" and prove the whole vertical slice in half a day:
use KirschbaumDevelopment\NovaChartjs\NovaChartjs;
public function fields(Request $request): array
{
return [
NovaChartjs::make('Monthly Usage', 'monthly_usage')
->chartType('line')
->chartData(fn () => [
'labels' => $this->usageLabels(),
'datasets' => [[
'label' => 'Requests',
'data' => $this->requestsPerMonth(),
'borderColor' => '#36a2eb',
'backgroundColor' => 'rgba(54,162,235,0.2)',
'fill' => true,
'tension' => 0.3,
]],
])
->onlyOnDetail(),
];
}
If the chart draws, you have cleared the only real risk in the dependency. If the canvas is blank with no console error, you have also cleared it — see Gotchas; that is the package's signature failure and it is diagnosable in about ten minutes.
Put the series in a testable class, not in the closure. The chartData closure runs on every detail-page load and its return value is serialized into the payload. Anything computed inline there is invisible to PHPUnit and untestable without booting Nova. Move aggregation to a small service or model method, and let the closure be a one-liner:
class ChartSeries
{
public static function monthlyCounts(Model $model, string $column, int $months = 12): array
{
$rows = $model->newQuery()
->where('created_at', '>=', now()->subMonths($months)->startOfMonth())
->toBase()
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as bucket, COUNT(*) as total")
->groupBy('bucket')
->orderBy('bucket')
->pluck('total', 'bucket');
$labels = $data = [];
foreach (range($months - 1, 0) as $i) {
$key = now()->subMonths($i)->format('Y-m');
$labels[] = $key;
$data[] = (int) ($rows[$key] ?? 0);
}
return ['labels' => $labels, 'data' => $data];
}
}
The gap backfill is the part people skip and the part that matters. A missing month silently compresses the x-axis, producing a trend line that is wrong in a way no one notices until a decision gets made on it — and no test will catch it unless you deliberately assert on an empty range.
Wrap the field in your own factory. This is the single highest-leverage thing you can do with a 51-star, zero-dependent package. One file becomes the swap point for a fork, a Chart.js replacement, or a move off Nova entirely:
class TrendChart
{
public static function make(
string $name,
string $attribute,
Closure $series,
string $color = '#36a2eb',
): NovaChartjs {
return NovaChartjs::make($name, $attribute)
->chartType('line')
->chartData(function () use ($series, $color, $name) {
['labels' => $labels, 'data' => $data] = $series();
return [
'labels' => $labels,
'datasets' => [[
'label' => $name,
'data' => $data,
'borderColor' => $color,
'backgroundColor' => $color.'33',
'fill' => true,
]],
];
})
->onlyOnDetail();
}
}
Every resource then reads TrendChart::make('Usage', 'usage', fn () => ChartSeries::monthlyCounts($this, 'requests')), and a Nova upgrade that breaks the package is a one-file edit instead of a grep across twenty resources.
Keep the palette as one constant. Pie and doughnut charts need a backgroundColor array. Hold a fixed set of six to eight accessible colors in a single class and reference it everywhere; otherwise every resource ends up looking like a different product.
Cache anything that touches a wide table. Each chart field on a detail view is one extra aggregate query per page load. Two charts is two queries — fine for an internal tool. Six charts over a table with tens of millions of rows is not fine, and the fix belongs in the closure, not in a queue:
->chartData(fn () => Cache::remember(
"acct:{$this->id}:usage",
now()->addMinutes(15),
fn () => ChartSeries::monthlyCounts($this, 'requests'),
))
Match chart type to the question, not to aesthetics. Line means time series, and only time series. Bar means discrete comparison. Pie/doughnut means a mix that genuinely sums to a whole (status breakdown, plan distribution) with six or fewer slices — past that the legend becomes unreadable and horizontal bar is the correct answer. Time series rendered as a pie is the most common misuse of this field and it makes the chart actively useless.
Use policies for visibility, not for convenience. Because the closure is evaluated server-side on render, ->canSee(fn ($request) => $request->user()->can('viewAnalytics', $this)) is load-bearing: a hidden field means the query never runs and the data never reaches the browser. That is a real security property, so use it for anything that leaks revenue or customer behaviour, and do not treat it as a cosmetic toggle.
Default to ->onlyOnDetail(). On index views the field renders per row, which multiplies your aggregate queries by page size. That is an N+1 you built deliberately, and it will only show up under load.
Testing strategy: unit-test the data, smoke-test the render. Cover ChartSeries for bucket backfill, timezone alignment, empty ranges, and integer casting. For the UI, keep one canary resource with a chart field and click through it after every Nova upgrade. Two minutes, and it is the entire regression suite this dependency justifies.
| Trap | What happens | Fix |
|---|---|---|
| Nova major drift | Blank canvas, silent — no PHP exception | Pin Nova; canary resource after every upgrade |
Bundled Chart.js major in dist/ |
Invisible to Dependabot and CVE scanners | Track CVEs manually; swap the dist asset if patched |
No destroy() on re-render |
Chart instances leak across repeated navigation | Read the component; if absent, avoid hot re-mounts |
| HTML tooltips containing user labels | XSS — canvas text is safe, HTML overlays are not | Keep labels trusted or escape before output |
MySQL SUM/COUNT returning strings |
Odd rendering, broken axis scales | Cast to int/float inside the series method |
| Eloquent collections in the payload | Serialization errors or bloated responses | ->toBase() + pluck(); arrays only |
| Empty dataset | Blank chart, no error, no warning | Render an explicit empty state or a zeroed series |
| Timezone bucketing done in SQL | Buckets shift against your reporting convention | DATE_FORMAT on a known timezone, or date_trunc on Postgres |
| App also imports Chart.js | Version conflict, duplicate registration | Do not import Chart.js anywhere in your app |
| CSP-locked environments | Assets blocked, blank canvas | Verify the package's init against your CSP first |
Debug the blank canvas in a fixed order. It is the package's signature failure and has exactly three usual causes: Nova component API mismatch after an upgrade, assets not loaded or blocked by CSP, or a closure that threw and returned null. Work them in that order — network tab for the package's JS, then Nova version, then dd() inside the closure to confirm it runs and returns the shape you believe it does. Do not start by rewriting the chart config; that is almost never the problem.
Assume you are the only user. Fifty-one stars and zero dependents means nobody is exercising your edge cases, and there is nobody to file a bug report with. The practical consequence is not "avoid it" — the blast radius is genuinely small because Chart.js does the rendering and this is a thin adapter — it is "budget for it." Estimate two to five engineer-days a year, concentrated around Nova majors, and treat the package as vendored rather than installed.
Accept that there is no refresh path. Every render is one server-side computation. No async reload, no date-range selector, no drill-down, no export, no cross-filtering. If a stakeholder asks for a date picker on the chart, the honest answer is that it is a different product decision, not a field option — and the right escalation is usually native Nova metrics or a real BI tool, not a package fork.
Keep logic in the config arrays, not the fluent chain. Your datasets and options arrays are near-verbatim portable to Blade, Filament, Livewire, or a standalone Chart.js page. Your NovaChartjs::make() calls are not. That asymmetry is the difference between a two-day migration and a two-week one when the package eventually lapses.
Watch point count. Chart.js handles roughly one to two thousand points comfortably with no downsampling, and the package offers none. A year of daily buckets is fine; five years of hourly rows is not. Aggregate or downsample in SQL before the data ever reaches the closure.
Do not import Chart.js in your own bundle. If you already have a Chart.js dependency for other parts of the app, you will end up with two copies and possibly two majors registered globally. Either drop your own import or use a different rendering path for those pages.
Fork on day one, not on the day it breaks. MIT license, small surface, readable in an afternoon. If you decide Nova compatibility requires a fork, set it up immediately with a private VCS entry and a pinned constraint — not during an upgrade window when you are improvising under pressure and the rest of the team is blocked.
How can I help you explore Laravel packages today?