Inspired by Leandro Ferreira’s Apex Charts plugin & Elemind's Echarts plugin this plugin delivers plotly.js integration for Filament.
- Installation
- Usage
- Setting a widget title
- Setting a widget subheading
- Setting a chart id
- Making a widget collapsible
- Setting a widget height
- Setting a widget footer
- Header & Footer Actions
- Hiding header content
- Filtering chart data
- Live updating (polling)
- Defer loading
- Loading indicator
- Chart overlay
- Post-init JS hook
- Dark / light theme sync
- Plotly event listeners
- Streaming support
- Streaming layout patch
- Changelog
- Contributing
- Credits
- License
You can install the package via composer:
composer require asharif88/filament-plotlyRegister the plugin for the Filament Panels you want to use:
use Asharif88\FilamentPlotly\FilamentPlotlyPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->plugins([
FilamentPlotlyPlugin::make()
]);
}Start by creating a widget with the command:
php artisan make:filament-plotly BlogPostsChartThe plugin uses the Plotly.react function to render charts.
This function takes in the chart data, layout and config as parameters.
You need to implement the getChartData() method to return an array with data, layout and config keys:
use Asharif88\FilamentPlotly\Widgets\PlotlyWidget;
class BlogPostsChart extends PlotlyWidget
{
protected function getChartData(): array
{
return [
'data' => [
[
'x' => ['2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05'],
'y' => [10, 15, 13, 17, 22],
'type' => 'scatter',
'mode' => 'lines+markers',
'name' => 'Blog Posts',
],
],
'layout' => [
'title' => 'Blog Posts Over Time',
'xaxis' => [
'title' => 'Date',
],
'yaxis' => [
'title' => 'Number of Posts',
],
],
'config' => [
'responsive' => true,
],
];
}
}Alternatively, you can set the data, layout and config separately by implementing the following methods:
protected function getChartData(): array
{
return [
[
'x' => ['2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05'],
'y' => [10, 15, 13, 17, 22],
'type' => 'scatter',
'mode' => 'lines+markers',
'name' => 'Blog Posts',
],
];
}
protected function getChartLayout(): array
{
return [
'title' => 'Blog Posts Over Time',
'xaxis' => [
'title' => 'Date',
],
'yaxis' => [
'title' => 'Number of Posts',
],
];
}
protected function getChartConfig(): array
{
return [
'responsive' => true,
];
}You may set a widget title:
protected static ?string $heading = 'Blog Posts Chart';Optionally, you can use the getHeading() method.
You may set a widget subheading:
protected static ?string $subheading = 'This is a subheading';Optionally, you can use the getSubheading() method.
You can add custom content before chart within the widget container using the getbeforeContent() method.
public function getBeforeContent(): null|string|Htmlable|View
{
return '...';
}You may set a chart id:
protected static string $chartId = 'blogPostsChart';You may set a widget to be collapsible:
protected static bool $isCollapsible = true;You can also use the isCollapsible() method:
protected function isCollapsible(): bool
{
return true;
}By default, the widget height is set to 300px. You may set a custom height:
protected static ?int $contentHeight = 400; //pxOptionally, you can use the getContentHeight() method.
protected function getContentHeight(): ?int
{
return 400;
}You may set a widget footer:
protected static ?string $footer = 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.';You can also use the getFooter() method:
Custom view:
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
protected function getFooter(): null|string|Htmlable|View
{
return view('custom-footer', ['text' => 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.']);
}<!--resources/views/custom-footer.blade.php-->
<div>
<p class="text-danger-500">{{ $text }}</p>
</div>Html string:
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
protected function getFooter(): null|string|Htmlable|View
{
return new HtmlString('<p class="text-danger-500">Lorem Ipsum is simply dummy text of the printing and typesetting industry.</p>');
}You can register Filament actions to appear in the widget header or footer. Override getHeaderActions() / getFooterActions() on your widget to return an array of Filament\Actions\Action (or ActionGroup) instances. These actions are rendered by Filament's actions component and respect alignment settings.
Example — simple header actions:
use Filament\Actions\Action;
use Filament\Support\Enums\Alignment;
protected function getHeaderActions(): array
{
return [
Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-refresh')
->action('updateOptions')
->button(),
Action::make('download')
->label('Download')
->url(route('reports.export'))
->color('secondary'),
];
}
protected function getHeaderActionsAlignment(): ?Alignment
{
return Alignment::End; // align header actions to the right
}Example — footer actions:
use Filament\Actions\Action;
use Filament\Support\Enums\Alignment;
protected function getFooterActions(): array
{
return [
Action::make('details')
->label('Details')
->url(route('reports.details'))
->button(),
];
}
protected function getFooterActionsAlignment(): ?Alignment
{
return Alignment::Center; // center footer actions
}Notes:
- You may return
ActionGroupinstances if you need grouped or dropdown actions. - Header actions are rendered next to any filter controls defined on the widget.
- Footer actions render above the footer content returned by
getFooter().
You can hide header content by NOT providing these
- $heading
- getHeading()
- $subheading
- getSubheading()
- getOptions()
You can set up chart filters to change the data shown on chart. Commonly, this is used to change the time period that chart data is rendered for.
You may use components from the Schemas to
create custom filters.
You need to use HasFiltersSchema trait and implement the filtersSchema() method to define the filter form schema:
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Filament\Widgets\ChartWidget\Concerns\HasFiltersSchema;
use Asharif88\FilamentPlotly\Widgets\PlotlyWidget;
class BlogPostsChart extends PlotlyWidget
{
use HasFiltersSchema;
public function filtersSchema(Schema $schema): Schema
{
return $schema->components([
TextInput::make('title')
->default('Blog Posts Chart'),
DatePicker::make('date_start')
->default('2025-07-01'),
DatePicker::make('date_end')
->default('2025-07-31'),
]);
}
/**
* Use this method to update the chart options when the filter form is submitted.
*/
public function updatedInteractsWithSchemas(string $statePath): void
{
$this->updateOptions();
}
}The data from the custom filter is available in the $this->filters array. You can use the active filter values within
your getChartData() method:
protected function getChartData(): array
{
$title = $this->filters['title'];
$dateStart = $this->filters['date_start'];
$dateEnd = $this->filters['date_end'];
return [
//chart options
];
}To set a default filter value, set the $filter property:
public ?string $filter = 'today';Then, define the getFilters() method to return an array of values and labels for your filter:
protected function getFilters(): ?array
{
return [
'today' => 'Today',
'week' => 'Last week',
'month' => 'Last month',
'year' => 'This year',
];
}You can use the active filter value within your getOptions() method:
protected function getOptions(): array
{
$activeFilter = $this->filter;
return [
//chart options
];
}By default, chart widgets refresh their data every 5 seconds.
To customize this, you may override the $pollingInterval property on the class to a new interval:
protected static ?string $pollingInterval = '10s';Alternatively, you may disable polling altogether:
protected static ?string $pollingInterval = null;This can be helpful when you have slow queries and you don't want to hold up the entire page load:
protected static bool $deferLoading = true;
protected function getChartData(): array
{
//showing a loading indicator immediately after the page load
if (!$this->readyToLoad) {
return [];
}
//slow query
sleep(2);
return [
//chart options
];
}You can change the loading indicator:
protected static ?string $loadingIndicator = 'Loading...';You can also use the getLoadingIndicator() method:
use Illuminate\Contracts\View\View;
protected function getLoadingIndicator(): null|string|View
{
return view('custom-loading-indicator');
}<!--resources/views/custom-loading-indicator.blade.php-->
<div>
<p class="text-danger-500">Loading...</p>
</div>Use getChartOverlay() to render HTML inside the chart container — directly on top of the Plotly canvas. This is the right place for progress bars, custom annotation panels, and loader overlays, because the content sits naturally inside the chart div without requiring document.getElementById or absolute-positioning tricks.
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Contracts\View\View;
protected function getChartOverlay(): null|string|Htmlable|View
{
return view('charts.my-overlay');
}{{-- resources/views/charts/my-overlay.blade.php --}}
<div id="stream-progress"
style="position:absolute;top:0;left:0;right:0;display:none"
class="h-1 bg-primary-500">
</div>The overlay is rendered inside a
wire:ignorecontainer, so it is set once on mount and then controlled by JavaScript. It will not be re-rendered by Livewire on subsequent updates.
Override getOnChartReadyScript() to run plain JavaScript the moment the Plotly chart is fully initialised. Inside the script, el refers to the chart DOM element.
protected function getOnChartReadyScript(): ?string
{
return <<<'JS'
// `el` is the Plotly chart DOM element
el.on('plotly_afterplot', () => {
document.getElementById('stream-progress').style.display = 'none';
});
JS;
}Use the HasChartTheme concern to keep Plotly's visual theme in sync with Filament's dark/light mode automatically. The Alpine component watches the dark class on <html> via a MutationObserver and calls Plotly.relayout() whenever the mode changes — no getFooter() workaround needed.
use Asharif88\FilamentPlotly\Concerns\HasChartTheme;
use Asharif88\FilamentPlotly\Widgets\PlotlyWidget;
class RevenueChart extends PlotlyWidget
{
use HasChartTheme;
protected function getDarkThemeLayout(): array
{
return [
'paper_bgcolor' => '#1e293b',
'plot_bgcolor' => '#1e293b',
'font' => ['color' => '#f1f5f9'],
];
}
protected function getLightThemeLayout(): array
{
return [
'paper_bgcolor' => '#ffffff',
'plot_bgcolor' => '#ffffff',
'font' => ['color' => '#0f172a'],
];
}
}The layout properties returned by each method are merged into the live chart layout via Plotly.relayout(). Return any valid Plotly layout keys.
Override getPlotlyEventListeners() to map Plotly JS events to public methods on your widget. When a mapped event fires, two things happen:
- The mapped method is called on this widget with a serialised payload.
- A window-level Alpine event
plotly:{event}is dispatched so that sibling Livewire components (a table, a slide-over) can also react without coupling to the chart widget.
protected function getPlotlyEventListeners(): array
{
return [
'plotly_click' => 'onChartClick',
'plotly_selected' => 'onChartSelected',
];
}
public function onChartClick(array $data): void
{
$recordId = $data['points'][0]['customdata'] ?? null;
$this->dispatch('open-record', id: $recordId);
}
public function onChartSelected(array $data): void
{
$ids = collect($data['points'])->pluck('customdata')->filter()->all();
$this->dispatch('filter-table', ids: $ids);
}Store the record's primary key in customdata when building trace data — that's the standard Plotly mechanism for carrying arbitrary metadata per point:
protected function getChartData(): array
{
$rows = Order::query()->get();
return [[
'x' => $rows->pluck('created_at'),
'y' => $rows->pluck('total'),
'customdata' => $rows->pluck('id'), // ← record IDs travel with each point
'type' => 'scatter',
'mode' => 'markers',
]];
}To react from a separate Livewire component (e.g. a ListOrders resource page), listen for the broadcasted window event:
use Livewire\Attributes\On;
#[On('plotly:plotly_click')]
public function handleChartClick(array $data): void
{
$this->tableFilters['id'] = $data['points'][0]['customdata'];
$this->resetTable();
}| Event | Payload fields |
|---|---|
plotly_click, plotly_hover, plotly_unhover, plotly_doubleclick |
points[] → {curveNumber, pointIndex, x, y, z, text, customdata, traceName} |
plotly_selected, plotly_deselect |
points[] + range, lassoPoints |
plotly_legendclick, plotly_legenddoubleclick |
{curveNumber, traceName} |
plotly_relayout, plotly_restyle, plotly_autosize |
raw layout/style change object |
DOM nodes, full trace objects, and axis definitions are stripped before serialisation — only safe, JSON-friendly values reach Livewire.
The HasStreamingSupport concern replaces the traditional getFooter() SSE boilerplate with a small set of override methods. The library owns the EventSource lifecycle, the progress overlay, and component cleanup — your widget only declares what is domain-specific.
Your server-side stream must emit JSON messages in this format:
| Message | Meaning |
|---|---|
{"init": true, "total": N} |
Stream is starting; N is the total number of data messages expected (used for the progress bar). |
{"done": true} |
Stream finished. The library closes the EventSource and removes the progress overlay. |
{ ...data } |
A data point. Forwarded to getOnStreamMessageScript(). |
use Asharif88\FilamentPlotly\Concerns\HasStreamingSupport;
use Asharif88\FilamentPlotly\Widgets\PlotlyWidget;
class LiveDataChart extends PlotlyWidget
{
use HasStreamingSupport;
public int $sourceId = 1;
// Return null to disable streaming (e.g. before required state is set)
protected function getStreamUrl(): ?string
{
return url('/stream/data');
}
// Query-string params appended to the URL on both initial load and every restart
protected function getStreamParams(): array
{
return ['source_id' => $this->sourceId];
}
// JS body called for each data message. `d` = parsed JSON, `el` = chart DOM element.
// Return null to use the built-in default: Plotly.extendTraces(el, {x:[[d.x]], y:[[d.y]]}, [0])
protected function getOnStreamMessageScript(): ?string
{
return <<<'JS'
Plotly.extendTraces(el, { x: [[d.ts]], y: [[d.value]] }, [0]);
JS;
}
// JS body called once when {done: true} arrives, after the overlay is removed.
// `el` is the chart DOM element. Return null if no post-stream work is needed.
protected function getOnStreamDoneScript(): ?string
{
return null;
}
protected function getChartData(): array
{
return [['x' => [], 'y' => [], 'type' => 'scatter', 'mode' => 'lines']];
}
}Returning
nullfromgetStreamUrl()disables streaming entirely — useful when required state (e.g. a selected source) has not been set yet.
The following shows HasStreamingSupport, HasChartTheme, and getPlotlyEventListeners() working together, which is the recommended pattern for a production streaming widget:
use Asharif88\FilamentPlotly\Concerns\HasChartTheme;
use Asharif88\FilamentPlotly\Concerns\HasStreamingSupport;
use Asharif88\FilamentPlotly\Widgets\PlotlyWidget;
use Livewire\Attributes\On;
class DailyVariationChart extends PlotlyWidget
{
use HasStreamingSupport;
use HasChartTheme;
protected ?string $pollingInterval = null;
protected static ?string $chartId = 'dailyVariationChart';
protected static int $contentHeight = 660;
public ?int $sourceId = null;
public ?string $dateFrom = null;
public ?string $dateTo = null;
// --- Filter handlers -----------------------------------------------------
#[On('sourceSelected')]
public function onSourceSelected(int $sourceId): void
{
$this->sourceId = $sourceId;
}
#[On('dateRangeChanged')]
public function onDateRangeChanged(?string $from, ?string $to): void
{
$this->dateFrom = $from;
$this->dateTo = $to;
}
// --- Chart data ----------------------------------------------------------
// Starts empty — data is streamed in via extendTraces
protected function getChartData(): array
{
return [[
'x' => [],
'y' => [],
'customdata' => [],
'mode' => 'lines+markers',
'line' => ['width' => 2, 'color' => 'orange'],
'marker' => ['size' => 6, 'color' => 'orange'],
'showlegend' => false,
]];
}
protected function getChartLayout(): array
{
return [
'title' => ['text' => 'Variations per day'],
'yaxis' => ['type' => 'category', 'autorange' => 'reversed'],
'autosize' => true,
'showlegend' => false,
];
}
protected function getChartConfig(): array
{
return ['responsive' => true];
}
// --- HasStreamingSupport -------------------------------------------------
protected function getStreamUrl(): ?string
{
return $this->sourceId ? url('/stream/data') : null;
}
protected function getStreamParams(): array
{
return [
'source_id' => $this->sourceId,
'date_from' => $this->dateFrom ?? '',
'date_to' => $this->dateTo ?? '',
];
}
// Expected message shape: { x: <date>, y: <float>, payloadId: <int> }
protected function getOnStreamMessageScript(): ?string
{
return <<<'JS'
Plotly.extendTraces(el, {
x: [[d.x]],
y: [[d.y]],
customdata: [[d.payloadId]],
}, [0]);
JS;
}
// --- HasChartTheme — automatic dark/light sync ---------------------------
protected function getDarkThemeLayout(): array
{
return [
'paper_bgcolor' => '#1e293b',
'plot_bgcolor' => '#1e293b',
'font' => ['color' => '#f1f5f9'],
];
}
protected function getLightThemeLayout(): array
{
return [
'paper_bgcolor' => '#ffffff',
'plot_bgcolor' => '#ffffff',
'font' => ['color' => '#0f172a'],
];
}
// --- Plotly event listeners ----------------------------------------------
protected function getPlotlyEventListeners(): array
{
return ['plotly_click' => 'onChartClick'];
}
public function onChartClick(array $data): void
{
$payloadId = $data['points'][0]['customdata'] ?? null;
if ($payloadId !== null) {
$this->dispatch('payloadSelected', payloadId: (int) $payloadId);
}
}
}Override getStreamingLayoutPatch() to declare layout properties that must be force-merged into the layout on every stream reset, regardless of what el.layout currently holds.
protected function getStreamingLayoutPatch(): array
{
return [
'template' => [
'layout' => [
'paper_bgcolor' => '#1e293b',
'plot_bgcolor' => '#1e293b',
],
],
];
}When to use this vs
HasChartTheme: If you useHasChartTheme, theme properties are tracked inel.layoutand are automatically preserved across stream resets — you do not needgetStreamingLayoutPatch()for theme sync. UsegetStreamingLayoutPatch()when you need to inject layout properties that are not managed byHasChartTheme, or when you cannot use that trait.
The dark mode is supported and enabled by default for the container.
Optionally, you can publish the views using
php artisan vendor:publish --tag="filament-plotly-views"Optionally, you can publish the translations using:
php artisan vendor:publish --tag=filament-plotly-translationscomposer testPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.