plotly/plotly.js
Plotly.js is an open-source JavaScript charting library for interactive, publication-quality graphs. It supports 40+ chart types, including statistical, 3D, scientific, and SVG/WebGL visualizations, with declarative APIs and MIT licensing.
Plotly.js is a client-side JavaScript library — there is no Composer package to install and no PHP API to learn. Your integration work in Laravel is therefore about delivery (Vite/npm vs CDN), data shaping (controller → JSON), and lifecycle management (mount/update/destroy) inside Blade, Alpine, Livewire, or Inertia.
Minimal path:
Install via npm + Vite (preferred over CDN so you control versioning and can tree-shake):
npm install plotly.js-dist-min
Import once in resources/js/app.js, or lazily per page (see Gotchas for bundle size):
import Plotly from 'plotly.js-dist-min';
window.Plotly = Plotly;
Return chart-ready data from the controller. Don't build Plotly config in Blade string literals; keep the shape in PHP and let @json handle escaping:
public function index()
{
$rows = Order::query()
->selectRaw('DATE(created_at) as day, SUM(total) as revenue')
->groupBy('day')->orderBy('day')->get();
return view('dashboard', [
'chart' => [
'data' => [[
'type' => 'scatter', 'mode' => 'lines+markers',
'x' => $rows->pluck('day'),
'y' => $rows->pluck('revenue'),
'name' => 'Revenue',
]],
'layout' => ['margin' => ['t' => 20], 'height' => 360],
],
]);
}
Mount it in Blade with a pushed script and a container that has a height:
<div id="revenue-chart" style="height:360px"></div>
@push('scripts')
<script>
const chart = @json($chart);
Plotly.newPlot('revenue-chart', chart.data, chart.layout, {
responsive: true,
displaylogo: false,
});
</script>
@endpush
Where to look first in the docs: Plotly.newPlot / Plotly.react / Plotly.purge, the layout reference (axes, margin, autosize, template), the config object (responsive, displayModeBar, scrollZoom), and the "Partial bundles" page for trimming the ~3 MB default build.
First real use case: a metrics dashboard page where the controller aggregates with SQL (GROUP BY) and hands one or more {data, layout} payloads to the view. That covers 80% of day-to-day usage; interactive drill-downs are the natural second step.
Controller → JSON endpoint for filtered charts. For anything with date pickers or dropdown filters, don't reload the page. Add a route returning the same {data, layout} shape and call it with fetch:
Route::get('/api/charts/revenue', fn (Request $r) => response()->json(
RevenueChart::make($r->date('from'), $r->date('to'))
));
Keep the chart-shaping logic in a small App\Support\Charts\* class so the dashboard and the API share one implementation. Cache expensive aggregates (Cache::remember) keyed on the filter set.
Reusable Blade component + Alpine wrapper. This is the most idiomatic Laravel pattern and avoids scattering <script> blocks:
{{-- resources/views/components/chart.blade.php --}}
@props(['id', 'spec', 'config' => []])
<div id="{{ $id }}" style="height:{{ $spec['layout']['height'] ?? 360 }}px"
x-data="plotlyChart(@js($spec), @js($config))"></div>
// resources/js/chart.js
document.addEventListener('alpine:init', () => {
Alpine.data('plotlyChart', (spec, config) => ({
chart: null,
init() {
this.chart = Plotly.newPlot(this.$el, spec.data, spec.layout, {
responsive: true, displaylogo: false, ...config,
});
this.$watch('spec', s => Plotly.react(this.$el, s.data, s.layout, config));
},
destroy() { Plotly.purge(this.$el); },
}));
});
Register it with Alpine.data globally so every chart on the page is declarative.
Livewire: wire:ignore plus explicit re-render. Livewire's DOM diffing will destroy Plotly's generated SVG. Wrap the container in wire:ignore, then listen for a browser event and call Plotly.react yourself:
<div wire:ignore id="rev" style="height:360px"
x-data
x-on:chart-updated.window="Plotly.react($el, $event.detail.data, $event.detail.layout)">
</div>
$this->dispatch('chart-updated', data: $this->chartData(), layout: $this->layout());
Never let Livewire re-render the div's children — only push data through events.
Inertia + Vue/React. Mount in onMounted, update in a watch, and always Plotly.purge(el) in onUnmounted. Forgetting the purge is the #1 cause of memory bloat and "ghost" resize listeners in SPA navigation.
Use Plotly.react, not newPlot, for updates. newPlot tears down and rebuilds (flicker, loses zoom state). react diffs. If the shape changes (different trace count/types), pass layout.datarevision or just newPlot again after purge — react can behave oddly when trace types change.
Theme from CSS variables. Don't hard-code colors. Read your Tailwind/theme tokens and inject them so dark mode "just works":
const css = getComputedStyle(document.documentElement);
const ink = css.getPropertyValue('--color-ink').trim();
const layout = { ...base, paper_bgcolor: 'transparent', plot_bgcolor: 'transparent',
font: { color: ink }, xaxis: { gridcolor: css.getPropertyValue('--color-grid') } };
Re-render on theme toggle by calling Plotly.relayout(el, {font: {color: newInk}}).
Drill-down via plot events. el.on('plotly_click', e => { ... }), fetch a detail endpoint, then Plotly.react a second chart or open a modal. Use customdata to carry IDs/URLs so you don't have to reverse-lookup from x/y.
Chart → image for reports. Plotly.toImage(el, {format: 'png', width: 1200, height: 600}) or Plotly.downloadImage. For server-generated PDFs (DomPDF/Snappy/Browsershot), the reliable route is Browsershot rendering the real page (Plotly needs a browser) — Plotly's own static export is Python's kaleido, which has no PHP equivalent. Don't promise server-side Plotly rendering in pure PHP.
Bundle trimming with partial bundles. If you only use a handful of trace types, import plotly.js/lib/core and register:
import Plotly from 'plotly.js/lib/core';
import scatter from 'plotly.js/lib/scatter';
import bar from 'plotly.js/lib/bar';
Plotly.register([scatter, bar]);
This is the single biggest perf win (3 MB → ~500 KB–1 MB).
Lazy-load per page with dynamic import. In Vite, const Plotly = await import('plotly.js-dist-min') inside the chart component keeps dashboards fast elsewhere. Combine with manualChunks in vite.config.js if you have several heavy pages.
Testing. In Dusk/Pest browser tests, assert on div.js-plotly-plot presence and on your data attributes/window state — not on generated SVG paths or <g> classes, which change between Plotly versions. Assert the JSON payload in a Feature test separately; that's where the real logic lives.
plotly.js import is enormous. Always prefer plotly.js-dist-min or partial bundles, and never import it into app.js if only one admin page needs charts.div with no height produces a 0px plot with no error. Set height inline or via a class; responsive: true only handles width.Plotly.Plots.resize(el) in the tab's shown callback.Cannot read properties of null. Fix: wire:ignore on the container, drive updates with dispatched events.Plotly.purge on teardown. Especially with Livewire wire:navigate, Inertia visits, and modals. Leaked instances keep resize observers and event handlers alive.json_encode fails on NaN/INF. A single bad aggregate (e.g. AVG over empty set, division by zero) returns false and @json emits nothing — the chart silently gets no data. Cast/round in PHP and replace non-finite values with null. Plotly treats null as a gap; NaN is invalid JSON.DATE() returns strings like 2025-01-01, which Plotly parses as UTC. If users are in another timezone the axis can look off by a day. Send ISO 8601 strings with offset, or use type: 'date' plus explicit tickformat, or send epoch millis.DECIMAL columns. PDO returns strings; Plotly will coerce but sorting/aggregation on the client can misbehave. Cast to float server-side for y arrays.@json in a @push block is fine, but avoid building JS with Blade interpolation. {{ $userInput }} inside a JS string is an XSS and quoting hazard. @json(...) / Js::from(...) handles escaping.hovertemplate or text, HTML in the tooltip renders. Sanitize or use hovertext with hoverinfo: 'text'.style-src without 'unsafe-inline' will break rendering. Either relax the policy for chart pages or accept the trade-off — there's no clean nonce story.scattergl (WebGL), drop markers, set hoverinfo: 'skip' or 'x+y', and downsample server-side (LTTB or bucketed averages). Plotly's client-side cost is dominated by SVG node count.newPlot clears zoom/pan. Use react for data ticks and relayout for cosmetic changes; only full newPlot when the trace schema changes.Plotly.newPlot(document.getElementById(id), ...), not string IDs, if you have several charts or dynamically named containers — string IDs collide inside Blade loops.package.json, and read the changelog before minor bumps if you rely on react update semantics or hover template behavior.config is not part of layout. responsive, displayModeBar, scrollZoom, toImageButtonOptions live in the config argument, not layout. Passing them in layout silently does nothing — a common source of "responsive doesn't work" reports.config: { scrollZoom: false, displayModeBar: false } on small screens, or the chart hijacks page scroll.How can I help you explore Laravel packages today?