uneca/plotly-chart-editor
Reactive Plotly.js chart builder for Laravel via Livewire. Sidebar-driven editor to configure traces and layout, multi-language UI (EN/FR/PT/ES), multiple sync modes and persistence options. Requires Plotly.js 3.x (peer dep), Alpine, PHP 8.4+.
A reactive chart builder for Laravel. This Livewire component gives your users a sidebar-driven editor to configure Plotly.js charts. It supports English, French, Portuguese, and Spanish.
uneca/plotly-chart-editor

window.Plotly in your app bundle (peer dependency — not shipped by this package)composer require uneca/plotly-chart-editor
Load Plotly.js before the package directives (choose one):
CDN:
<script src="https://cdn.plot.ly/plotly-3.5.0.min.js"></script>
or npm:
npm install plotly.js-dist-min@^3.0.0
// resources/js/app.js
import Plotly from 'plotly.js-dist-min';
window.Plotly = Plotly;
Then add these Blade directives to your layout's <head> after Plotly.js:
@plotlyChartEditorStyles
@plotlyChartEditorScripts
{{-- Livewire 4 bundles Alpine; do not load Alpine separately --}}
@livewireStyles
Place @livewireScripts before </body>.
<livewire:plotly-editor
:data-sources="$rawDataset"
:trace-types="['bar', 'line', 'scatter', 'pie', 'histogram']"
/>
Full control:
<livewire:plotly-editor
:data-sources="$rawDataset"
:data="$traces"
:layout="$globalLayout"
:config="$plotlyConfig"
:trace-types="['bar', 'line', 'scatter']"
:preload-schema="true"
:sync-mode="'hybrid'"
:show-export="true"
:show-data-viewer="true"
/>
The editor fills its container. For a full-page editor without scrollbars, wrap <livewire:plotly-editor> in a flex container with height: 100vh:
Inline style:
<div style="height: 100vh; display: flex; flex-direction: column;">
{{-- Optional title bar --}}
<h1 style="flex-shrink: 0;">Edit chart</h1>
<livewire:plotly-editor ... />
</div>
Tailwind:
<div class="h-screen flex flex-col">
<h1 class="shrink-0">Edit chart</h1>
<livewire:plotly-editor ... />
</div>
The @plotlyChartEditorStyles directive includes the inner flex rules — only the wrapper needs explicit sizing.
| Prop | Type | Default | Description |
|---|---|---|---|
dataSources |
array |
required | Key-value pool of columns. Immutable after mount. |
data |
array |
[] |
Pre-populated traces (each with optional meta.columnNames for column-referenced bindings). |
layout |
array |
[] |
Initial Plotly layout config. |
config |
array |
['responsive' => true] |
Plotly config flags. |
traceTypes |
array |
['bar'] |
Enabled trace types; first is the default for new traces. |
preloadSchema |
bool |
true |
Load all enabled schema profiles on mount. |
syncMode |
string |
'manual' |
manual | auto | hybrid (see Sync modes below). |
showExport |
bool |
true |
Show the Export dropdown in the footer. |
showDataViewer |
bool |
true |
Show the Data viewer button in the footer. |
dataSources shape$dataSources = [
'Country' => ['Ghana', 'Kenya', 'Nigeria'],
'Population' => [34, 55, 223],
];
All columns should be the same length. Length mismatches produce a non-blocking inline warning.
| Mode | Behaviour | Save button |
|---|---|---|
manual |
Syncs only when the user clicks Save. | Visible |
auto |
Debounced (~500ms) sync after each mutation. Shows "Synced ✓" on success. | Hidden |
hybrid |
Auto-sync AND a Save button for forced immediate sync. | Visible |
The footer includes a Data button (visible when showDataViewer is true, which is the default) that opens a modal table of all dataSources columns. Each column key is a table header, and the rows display the corresponding array values. This is read-only — for editing column values, modify the dataSources prop.
The modal can be closed by clicking the × button, clicking the overlay backdrop, or pressing Escape.
| Event | Payload | When |
|---|---|---|
chart-synced |
{ data: array, layout: array } |
After every successful sync (Livewire dispatch). |
ChartSynced (native) |
$data + $layout |
Same moment, as a Laravel event class. |
plotly-chart-editor:synced |
{ traces, layout } |
Same moment, as a browser CustomEvent. |
plotly-chart-editor:sync-failed |
{ error } |
On sync failure, as a browser CustomEvent. |
Payload notes: Traces in event payloads carry
meta.columnNames(column references) but not the resolved data arrays. The actual data lives indataSourceson the server. To render a chart outside the editor, hydrate the traces by resolvingmeta.columnNamesagainst your dataset — or usegetCompiledTraces()to get Plotly-native traces with type aliases resolved andmetastripped. See "Loading an existing chart" below.
You have several options to save chart data from the editor to your backend.
Choose the one that fits your app's architecture.
Schema::create('charts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('title')->nullable();
$table->json('traces'); // array of Plotly trace objects
$table->json('layout'); // Plotly layout object
$table->timestamps();
});
Wrap <livewire:plotly-editor> in your own Livewire component. The Livewire chart-synced event bubbles up to parents, so #[On] works directly.
use App\Models\Chart;
use Livewire\Attributes\On;
class EditChart extends \Livewire\Component
{
public Chart $chart;
public array $rawDataset;
public function mount(Chart $chart): void
{
$this->chart = $chart;
$this->rawDataset = [
'Country' => ['Ghana', 'Kenya', 'Nigeria'],
'Population' => [34, 55, 223],
'GDP' => [72, 95, 446],
];
}
#[On('chart-synced')]
public function onChartSynced(array $data, array $layout): void
{
$this->chart->update([
'traces' => $data,
'layout' => $layout,
]);
}
public function render()
{
return view('livewire.edit-chart');
}
}
{{-- resources/views/livewire/edit-chart.blade.php --}}
<div style="height: 100vh; display: flex; flex-direction: column;">
<h1 style="flex-shrink: 0;">{{ $chart->title }}</h1>
<livewire:plotly-editor
:data-sources="$rawDataset"
:data="$chart->traces"
:layout="$chart->layout"
:sync-mode="'hybrid'"
/>
</div>
Pros: Clean PHP-only integration, easy to re-render other page parts.
Cons: Requires a Livewire component just to wrap the editor.
No wrapping needed. PlotlyEditor dispatches chart-synced as a browser event,
so any other Livewire component on the page can listen and react.
{{-- Parent view --}}
<livewire:plotly-editor :data-sources="$data" />
<livewire:save-button />
Frontend (JS):
{{-- resources/views/livewire/save-button.blade.php --}}
@script
<script>
Livewire.on('chart-synced', ({ data, layout }) => {
// Persist $data and $layout however you like
});
</script>
@endscript
Backend (PHP): Use the #[On] attribute on a method in the sibling
component — no JS needed at all.
use Livewire\Attributes\On;
use Livewire\Component;
class SaveButton extends Component
{
#[On('chart-synced')]
public function persist(array $data, array $layout): void
{
// Persist $data and $layout
}
public function render(): View
{
return view('livewire.save-button');
}
}
Pros: No wrapping — components stay independent. Backend approach needs no JS.
Cons: The sibling component must register a listener (JS or #[On]).
No Livewire component at all. The editor lives in a regular Blade view; save via fetch.
{{-- Not inside a Livewire component — a plain Blade view --}}
@extends('layouts.app')
@section('content')
<livewire:plotly-editor :data-sources="$countries" sync-mode="hybrid" />
@endsection
@push('scripts')
<script>
Livewire.on('chart-synced', ({ data, layout }) => {
fetch('/charts/{{ $chart->id }}/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ traces: data, layout }),
});
});
</script>
@endpush
Pros: Zero Livewire boilerplate in the host.
Cons: You handle CSRF, validation, and routing yourself.
The ChartSynced event class is dispatched alongside chart-synced. Register a listener.
// App\Providers\EventServiceProvider.php
protected $listen = [
\Uneca\PlotlyChartEditor\Events\ChartSynced::class => [
\App\Listeners\SaveChart::class,
],
];
// App\Listeners\SaveChart.php
class SaveChart
{
public function handle(\Uneca\PlotlyChartEditor\Events\ChartSynced $event): void
{
// $event->data, $event->layout
// Save to database, trigger a job, etc.
}
}
Pros: Decoupled from Livewire entirely. Can be queued.
Cons: Requires registering a listener (one-time setup).
The package dispatches plotly-chart-editor:synced on window. Listen from any JS framework.
window.addEventListener('plotly-chart-editor:synced', (e) => {
const { traces, layout } = e.detail;
// Send to backend, update a store, etc.
});
window.addEventListener('plotly-chart-editor:sync-failed', (e) => {
console.error('Chart sync failed:', e.detail.error);
});
Pros: Framework-agnostic. Works with React, Vue, vanilla JS.
Cons: Manual HTTP wiring.
Read chart state directly from Alpine.store('chartBuilder') at any time.
<livewire:plotly-editor :data-sources="$data" />
<button x-data @click="fetch('/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
traces: Alpine.store('chartBuilder').traces,
layout: Alpine.store('chartBuilder').layout,
}),
})">Save</button>
Pros: Full control, no event wiring.
Cons: You must handle dirty-state tracking yourself.
The editor syncs traces in reference form — each trace carries meta.columnNames (e.g. {'x': 'Country', 'y': 'Population'}) instead of raw data arrays. The actual column data lives in dataSources.
Pass stored traces and layout back to the editor:
<livewire:plotly-editor
:data-sources="$rawDataset"
:data="$chart->traces"
:layout="$chart->layout"
/>
The editor's Alpine store resolves meta.columnNames against dataSources at render time via compileTrace(), so the chart renders with the correct data.
For read-only display (outside the editor), resolve the bindings yourself or use getCompiledTraces() (see below).
getCompiledTraces()When serving stored traces to Plotly directly (outside the editor), some type aliases need resolving (area → scatter, line → scatter). Call this method on the component:
$compiled = $component->getCompiledTraces();
// e.g. ['type' => 'area', ...] → ['type' => 'scatter', ...]
ValidChartConfig ruleValidate incoming chart payloads in your controllers:
use Uneca\PlotlyChartEditor\Rules\ValidChartConfig;
$request->validate([
'chart' => ['required', new ValidChartConfig],
]);
None of the following publishes are required for the editor to work. Use them only if you need to override defaults.
php artisan vendor:publish --tag="plotly-chart-editor-config" # trace type profiles
php artisan vendor:publish --tag="plotly-chart-editor-translations" # language strings
php artisan vendor:publish --tag="plotly-chart-editor-assets" # static JS/CSS to public/
config/plotly-chart-editor.php to add or modify trace type profiles (see Adding a new trace type profile).resources/lang/vendor/plotly-chart-editor.public/vendor/ bypasses the PHP route for a minor production perf gain.All visual tokens are CSS custom properties declared in :root. Override them in your own stylesheet:
/* my-app.css */
:root {
--plotly-editor-accent: #7c3aed; /* purple accent */
--plotly-editor-sidebar-w: 320px; /* narrower sidebar */
--plotly-editor-bg: #1e1e2e; /* dark background */
--plotly-editor-surface: #2a2a3e;
--plotly-editor-border: #44446a;
--plotly-editor-text: #cdd6f4;
--plotly-editor-text-muted: #6c7086;
}
| Token | Default | Purpose |
|---|---|---|
--plotly-editor-bg |
#ffffff |
Root and canvas background |
--plotly-editor-surface |
#f8fafc |
Sidebar and fold-header background |
--plotly-editor-border |
#e2e8f0 |
All borders and dividers |
--plotly-editor-text |
#0f172a |
Primary text |
--plotly-editor-text-muted |
#64748b |
Labels and secondary text |
--plotly-editor-accent |
#2563eb |
Active states, Save button, focus ring |
--plotly-editor-accent-fg |
#ffffff |
Text on accent-colored surfaces |
--plotly-editor-warning |
#d97706 |
Warning badge and inline messages |
--plotly-editor-danger |
#dc2626 |
Delete button hover |
--plotly-editor-success |
#16a34a |
"Saved ✓" and "Copied ✓" messages |
--plotly-editor-radius |
6px |
Border radius for controls and folds |
--plotly-editor-sidebar-w |
380px |
Sidebar width |
--plotly-editor-font |
system-ui, sans-serif |
Editor font stack |
config/plotly-chart-editor.php.profiles:'bubble' => [
'groups' => [
'Data' => [
'label' => 'Data',
'fields' => [
['key' => 'x', 'label' => 'X', 'type' => 'column'],
['key' => 'y', 'label' => 'Y', 'type' => 'column'],
['key' => 'marker.size', 'label' => 'Size', 'type' => 'column'],
],
],
],
],
'bubble' in traceTypes:<livewire:plotly-editor
:data-sources="$data"
:trace-types="['bar', 'scatter', 'bubble']"
/>
Pre-built types are loaded on mount. Exotic types are lazy-loaded on first use via a single Livewire request, then cached in the Alpine store for the remainder of the session.
composer install && npm install
vendor/bin/pest # run tests
vendor/bin/pint # fix code style
npm run build # build assets
vendor/bin/testbench serve # start workbench dev server
Browser tests are gated behind --group=browser and require a browser driver.
MIT — see LICENSE.md.
How can I help you explore Laravel packages today?