matheusmarnt/livecharts
LiveCharts is a reactive chart abstraction for Laravel that unifies ApexCharts and Chart.js behind one fluent PHP API. Define 18 chart types in PHP, render with a single Livewire component, and update them reactively from application state—no JavaScript boilerplate.
Minimal setup (5 minutes):
composer require matheusmarnt/livecharts
php artisan livecharts:install # config + public/vendor/livecharts/js + optional stubs
Then place the directive exactly once in your layout — before @livewireScripts:
@liveChartsScripts
@livewireScripts
That ordering is non-negotiable: livecharts.js registers its Alpine components before Alpine boots. Getting it wrong produces a blank canvas with no console error.
Where to look first, in this order:
php artisan livecharts:preview — opens a gallery of all 18 chart types. Do this before writing a single line; it tells you which engine/type combos actually render in your environment.config/livecharts.php — default engine (apexcharts), asset mode, theme mode.src/Charts/Chart.php — the fluent surface lives here; this is the real documentation, since the README only samples it.src/Engines/ — the ApexCharts and Chart.js adapters. Read these the moment you hit a config question the README doesn't answer.docs/installation.md — asset modes and engine selection details.First use case — a KPI chart in an existing dashboard widget. Build the chart inside render() so it's reconstructed from fresh data on every Livewire request:
// app/Livewire/Dashboard/RevenueWidget.php
public function render()
{
return view('livewire.dashboard.revenue-widget', [
'chart' => LiveCharts::area()
->title(__('Revenue'))
->labels($this->months)
->dataset('2026', $this->totals)
->palette(TwPalette::Vibrant)
->height(320)
->theme('auto'),
]);
}
{{-- livewire/dashboard/revenue-widget.blade.php --}}
<div>
<livewire:livecharts :chart="$chart" />
</div>
Because the chart is a render() argument, changing $this->months / $this->totals via wire:model.live or any action re-renders the chart automatically — that's the whole "reactive" story, and it costs you zero JavaScript. Only reach for polling or broadcasting when you need updates the user didn't trigger.
Fluent for one-offs, class-based for anything reused or filtered. Inline builders are right for dashboard tiles. As soon as a chart appears in two places, or you want it unit-testable, promote it:
php artisan make:chart RevenueChart --type=bar --engine=chartjs
class RevenueChart extends Chart
{
protected string $type = 'bar';
public function __construct(public array $data = [])
{
parent::__construct();
$this->title('Revenue')
->labels(array_keys($this->data))
->datasets([
Dataset::make('2026')->data(array_values($this->data))->color('#10B981'),
]);
}
}
Keep constructors cheap and side-effect free — they run on every hydration cycle in which the chart is rebuilt.
Multi-series and mixing. Dataset::make() per series, then set stacked() on bars or mix types (supported since 1.12) when a chart needs a line overlay on bars. Put per-series color on the dataset, not the chart-level ->colors(), once you exceed two series — palette presets plus explicit dataset colors is the least surprising combination.
Filter-driven drill-down is the highest-value pattern and the reason to prefer this package over hand-rolled JS:
// child chart config
$chart->onDataPointClick('revenue-drilldown');
// parent Livewire component
#[On('revenue-drilldown')]
public function drill(array $data): void
{
$this->region = $data['label']; // ['seriesIndex','dataPointIndex','value','label']
$this->period = $data['seriesIndex'];
}
The parent re-renders, the child receives a new chart payload, the engine redraws. No JS, no event bus, no $wire juggling.
Near-real-time via polling. ->poll(5000) makes the component emit wire:poll="refresh" and fire a livecharts:refreshed browser event. Important: polling re-renders the chart component with the serialized payload — it does not re-execute your query. Hook either side:
// parent
#[On('livecharts:refreshed')]
public function refreshData(): void { $this->totals = $this->query(); }
// or client-side, e.g. to trigger an external fetch
window.addEventListener('livecharts:refreshed', (e) => fetch(`/api/${e.detail.id}`));
For push-style updates (queues, websockets), ->broadcastOn('private-charts.'.$user->id)->broadcastAs('chart.updated') plus an Echo subscription avoids polling entirely.
Engine selection is a per-chart decision, not a global one. Leave apexcharts as the default for interactive charts (zoom, selection, scroll, annotations), and switch individual charts to chartjs when you want the lighter bundle or you're already loading Chart.js for legacy widgets:
LiveCharts::line()->engine('chartjs')->labels(...)->dataset(...);
Note that Chart.js-only chart types (treemap, matrix, sankey) depend on the plugin bundles shipped in resources/dist — verify they load under your asset mode before committing to those types.
Theming: use tokens, not hex. TwColor gives you 289 cases with a dark: / light: named-argument API, and the JS observer re-colors live on dark-mode toggle with no Livewire roundtrip:
->titleColor(dark: TwColor::Amber300, light: TwColor::Amber600)
->titleFont(size: 18, weight: 'bold', family: 'Inter')
->theme('auto')
This is the package's strongest differentiator — hand-rolled ApexCharts configs almost always get dark mode wrong because color decisions are made once at render time.
Custom engines are a legitimate extension point, not a novelty. Implement Matheusmarnt\LiveCharts\Contracts\EngineAdapter and register it:
LiveCharts::registerEngine('highcharts', HighchartsAdapter::class);
Do this in a service provider, not a controller. Use the built-in Apex adapter as your template — it's the more feature-complete of the two, so mapping to it first gives you the widest chart-type coverage.
Escape hatches before forking. The 1.13 specialized config helpers (xaxis, yaxis, grid, and friends) exist so you don't have to drop to raw engine options. Exhaust those, then LiveCharts::registerEngine(), then vendor-level customization via the published stubs (stubs/livecharts/chart.stub) — in that order.
Asset mode both is the default and it requires published files. If you skip livecharts:install, or your deploy pipeline doesn't run it, charts render empty with a 404 for the engine bundle. Fix on every deploy:
php artisan vendor:publish --tag=livecharts-assets --force
Or set LIVECHARTS_ASSETS_MODE=cdn in .env and skip the publish step entirely. Set local only when you have a real reason (CSP, air-gapped, offline) — otherwise you own keeping public/vendor/livecharts/js in sync across upgrades. Add the --force publish to your deploy script, because package upgrades ship new bundles.
Do not build the chart in a mount()ed public property unless it serializes cleanly. Livewire snapshots the chart between requests. Any closure, resource, Eloquent model with lazy relations, or engine-specific object inside the payload will break hydration with a confusing error. Building the chart in render() sidesteps this class of bug entirely.
Polling does not refresh your data. This is the single most common misunderstanding: ->poll(5000) re-renders the component, but the dataset comes from the serialized chart. The README says it plainly — hydrate fresh data in a parent refresh() or listen for livecharts:refreshed. If your chart is stale after polling, this is why.
Labels and dataset lengths must match. Since 1.15 the package throws EmptyDataset and DataShapeMismatch rather than silently rendering garbage — good, but it means a groupBy that occasionally drops a month will now throw at render time. Validate shape in a test, or pad your series before handing it to the builder.
Engine parity is not guaranteed across all 18 types. Don't assume heatmap or sankey behaves identically on both engines, and don't assume the Chart.js plugin bundles are loaded just because the type is listed. Verify with livecharts:preview on your actual asset mode before designing a page around a chart type.
Dark mode detection keys off <html class="dark"> (or prefers-color-scheme). If your theme toggle puts the class on <body>, or uses data-theme, ->theme('auto') will silently stay in light mode. The live re-color without a roundtrip only works when detection succeeds — so check that first when colors "don't update."
@liveChartsScripts must be in a layout that survives wire:navigate. The package has idempotency and navigate tests, but they can't save you if the directive sits in a partial that gets swapped. Put it in your persistent app layout, before @livewireScripts, and leave it alone.
Config changes need a cache clear. config/livecharts.php is read by the service provider; after changing the default engine or asset mode, run php artisan config:clear locally and make sure config:cache runs after you've edited the file in production.
Pin the version and read the changelog on every bump. This is a 5-star, zero-dependent package that went from 1.4 to 1.18 with features landing fast (mixed charts, config helpers, exceptions, i18n, registerEngine, polling) and PHPStan bumped 5→8 mid-stream. Use a caret constraint and skim CHANGELOG before upgrading — several releases changed behavior (the $attributes binding fix in 1.16.0 is exactly the kind of change that alters rendered markup). Consider vendoring/pinning the source if charts are business-critical.
Debugging order when a chart is blank: (1) directive placement and bundle 404s in the network tab, (2) ->toArray()/payload dump on the chart to confirm data shape, (3) switch ->engine() to isolate adapter-specific issues, (4) reproduce in livecharts:preview. That sequence resolves nearly every report without touching the package source.
Localization is built in for en, pt_BR, and es — route chart titles and legend labels through __() so you inherit that rather than hardcoding strings in chart classes.
How can I help you explore Laravel packages today?