nnnick/chartjs
Chart.js for Laravel: a fluent PHP wrapper for building interactive charts. Create datasets and options with a clean API, render them in Blade, and ship responsive visualizations without writing JavaScript. Includes a Blade directive and support for all Chart.js chart types.
Chart.js isn't a Laravel package — it's a browser-side JavaScript charting library (nnnick/Chart.js redirects to chartjs/Chart.js; the distributable you actually install is chart.js on npm). So "installing" it means wiring it into your asset pipeline, not adding a service provider.
Minimal path in a modern Laravel app:
npm install chart.js
// resources/js/app.js
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
window.Chart = Chart; // only if you're using inline Blade scripts
Then reference it from a Blade view. The first use case worth building is the one you'll repeat forever: a controller returns aggregated data, the view hands it to the chart as JSON.
// DashboardController
return view('dashboard', [
'revenue' => Order::selectRaw('DATE(created_at) as day, SUM(total) as total')
->groupBy('day')->orderBy('day')->pluck('total', 'day'),
]);
<div class="relative h-64">
<canvas id="revenueChart"></canvas>
</div>
<script>
const el = document.getElementById('revenueChart');
new Chart(el, {
type: 'line',
data: {
labels: @json($revenue->keys()),
datasets: [{ label: 'Revenue', data: @json($revenue->values()), tension: 0.3 }]
}
});
</script>
Where to look first: the "Chart Types" and "Configuration" sections of the docs (chartjs.org/docs), then the per-chart-type options pages. Skip the v2-era blog posts entirely — the API changed heavily in v3 and again in v4.
Pass data via data-* attributes, not inline JS. Inline scripts break under strict CSP and get awkward with localization. A reusable Blade component keeps it clean:
{{-- resources/views/components/chart.blade.php --}}
@props(['type' => 'line', 'labels' => [], 'datasets' => [], 'options' => []])
<div {{ $attributes->merge(['class' => 'relative']) }} style="height: 18rem">
<canvas
data-chart
data-type="{{ $type }}"
data-labels='@json($labels)'
data-datasets='@json($datasets)'
data-options='@json($options)'></canvas>
</div>
// resources/js/chart-init.js
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
document.querySelectorAll('[data-chart]').forEach((canvas) => {
const d = canvas.dataset;
new Chart(canvas, {
type: d.type,
data: { labels: JSON.parse(d.labels), datasets: JSON.parse(d.datasets) },
options: JSON.parse(d.options || '{}'),
});
});
Alpine.js is the natural pairing. You get reactivity without a SPA:
<div x-data="revenueChart(@js($revenue))" class="relative h-64">
<canvas x-ref="canvas"></canvas>
</div>
Alpine.data('revenueChart', (initial) => ({
chart: null,
init() {
this.chart = new Chart(this.$refs.canvas, { /* ... */ });
this.$watch('series', () => { this.chart.data.datasets[0].data = this.series; this.chart.update(); });
},
destroy() { this.chart?.destroy(); },
}));
Livewire: always wrap the canvas in wire:ignore. Livewire's DOM diffing will otherwise clone/destroy the canvas element and you'll get blank charts after every poll:
<div wire:ignore class="relative h-64">
<canvas id="salesChart" wire:ignore></canvas>
</div>
Then update from the Livewire side using a dispatched event rather than re-rendering:
$this->dispatch('chart-updated', data: $this->series);
Livewire.on('chart-updated', ({ data }) => {
chart.data.datasets[0].data = data;
chart.update();
});
Inertia/Vue/React: treat the chart as a ref-mounted side effect, destroy on unmount, and re-create when props change identity. Don't try to be clever with partial updates until you've measured a problem.
Server-side aggregation is the pattern that matters. Chart.js will happily render 50,000 points, but nobody can read them. Aggregate in SQL (DATE_TRUNC, GROUP BY), bucket into weeks/months for long ranges, and let Chart.js handle presentation only. For genuinely large series, enable the decimation plugin or downsample in PHP.
Dashboard composition: one controller, one view, N chart components, each receiving a small array. Keep chart configs in PHP config or a JS module rather than scattering literals across Blade templates — you'll want consistent brand colors, fonts, and number formatting.
"Canvas is already in use" is the #1 error. It means you called new Chart() twice on the same canvas — almost always from Livewire re-rendering, Turbo navigation, or a hot-reload loop. Fix with wire:ignore, or store the instance (Chart.getChart(canvas)?.destroy()) before re-creating.
Always give the canvas a sized, position: relative parent and set options.responsive = true, maintainAspectRatio = false. Otherwise the chart grows unboundedly on every resize tick or collapses to zero height inside a flex container.
Prefer mutating chart.data + chart.update() over rebuilding. Rebuilding loses animation state, resets tooltips, and leaks listeners. For high-frequency updates (polling, streaming), pass chart.update('none') to skip animation entirely.
Version drift is brutal. v2 (new Chart(ctx, {...}) with scales: { xAxes: [...] }) is not v3/v4 (scales: { x: {...} }). Most StackOverflow answers you'll find are v2. Pin your major version and check the migration guide before upgrading. v4 also requires explicit Chart.register(...) — if you tree-shake and forget a controller, you get a silent blank canvas or "is not a registered controller".
Time-series axes need a date adapter. type: 'time' fails with a cryptic error unless you install chartjs-adapter-date-fns (or luxon/dayjs) and import it. It's a separate package from Chart.js.
Colors and fonts default to ugly on dark themes. Set Chart.defaults.color, Chart.defaults.borderColor, and Chart.defaults.font.family once at boot, or pass them per-chart. They don't read your CSS variables automatically — you can read them yourself with getComputedStyle if you support a theme toggle.
Tooltip and tick formatting is where polish lives. Use callbacks.label for currency/units and scales.y.ticks.callback for abbreviated numbers ($1.2k). Do this in JS, not by pre-formatting strings in PHP — pre-formatted strings break sorting and the underlying numeric scale.
Exporting to PNG: canvas.toDataURL('image/png') returns a transparent background unless you set backgroundColor in the chart config or draw onto an offscreen canvas first. If you need server-side PDFs with charts, render with a headless browser (Browsershot/Puppeteer) — do not try to reimplement the chart in PHP.
Accessibility: <canvas> has no accessible content. Add role="img" and an aria-label summarizing the trend, and consider a visually-hidden <table> of the same data. Chart.js doesn't do this for you.
Extension points worth knowing: custom plugins (plugins: [{ afterDraw(chart) { ... } }]) for watermarking or annotation lines, chartjs-plugin-annotation and chartjs-plugin-datalabels for common needs, and onClick handlers on the chart or legend for drill-down navigation.
Debugging: if the canvas is blank, check in this order — is Chart registered, is the canvas parent sized, is the data array non-empty, are labels defined, is there a JS error before new Chart(). Chart.getChart(canvas) tells you whether an instance already exists.
How can I help you explore Laravel packages today?