zaimealabs/charts
zaimealabs/charts is a Laravel/PHP charting package for creating and rendering charts with a simple, expressive API. It helps turn datasets into clear visualizations for dashboards, reports, and app views.
Install and publish.
composer require zaimealabs/charts
php artisan vendor:publish --provider="ZaimeaLabs\Charts\ChartsServiceProvider"
Before writing any chart, confirm three things in composer.json and the README, because they determine whether the rest of the setup works at all:
illuminate/support and PHP constraints against your application.public/vendor, or CDN-loaded (this drives your CSP and deploy story).Then clear config cache (php artisan config:clear) and verify the published config/charts.php is what the app actually loads.
Where to look first.
config/charts.php — colors, defaults, asset path, component namespace.vendor/zaimealabs/charts/src/ — the fluent builder and any Dataset/Series classes. Skim these before trusting the README; the builder is the real API surface.resources/views/vendor/... after publishing views, if you need to override.First use case. One line chart on an internal admin dashboard, fed by a pre-aggregated Eloquent query. Not a customer-facing page, not a live-updating chart.
// A dedicated class, not a controller closure
final class SignupsPerDay
{
public function chart(): \ZaimeaLabs\Charts\Chart
{
$rows = User::query()
->where('created_at', '>=', now()->subDays(30))
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->groupBy('day')
->orderBy('day')
->pluck('total', 'day');
return Chart::line()
->labels($rows->keys()->all())
->dataset('Signups', $rows->values()->map(fn ($v) => (int) $v)->all())
->colors(['#4f46e5']);
}
}
{{-- dashboard.blade.php --}}
<x-charts::chart :chart="$signupsChart" />
Render it, view source, and read the emitted options object. That single inspection answers most of the questions below.
Keep chart construction out of controllers. Controllers and Livewire components should hand a chart object (or a rendered string) to the view, nothing more. Put each chart in a small invokable class (SignupsPerDay, RevenueByPlan) under app/Charts/. This gives you a testable unit, a place for caching, and a natural seam if you later swap renderers.
Always aggregate in SQL, never in PHP. The package will happily serialize 50,000 points into the HTML. Push groupBy/selectRaw/date truncation into the query and pass the package a small, already-shaped array. If a series genuinely needs high resolution, downsample server-side (e.g. hourly → daily for ranges over 30 days) before it reaches the builder.
->dataset('Revenue', $rows->pluck('total')->map(fn ($v) => (float) $v)->all())
Normalize types at the boundary. MySQL/Postgres drivers return aggregate columns as strings. Cast every value to int/float before handing it to the builder — ApexCharts will render string-typed numeric series as categories or drop them silently. Do the same for labels: format dates in PHP to a fixed string (Y-m-d) rather than passing Carbon objects and hoping the serializer agrees with the browser's locale.
Adapter boundary (worth the one day). Define an internal contract so no view or controller ever references ZaimeaLabs\Charts\* directly:
interface ChartRenderer
{
public function render(ChartData $data): string; // or Htmlable
}
final class ZaimeaChartRenderer implements ChartRenderer { /* ... */ }
ChartData is your value object: title, type, labels, series, colors. Every call site depends on ChartData and ChartRenderer. This package is a single-maintainer, low-activity dependency; the adapter converts "the package is abandoned" from a rewrite into a one-file change. Land the interface before the package so convenience pressure never gets a chance to bypass it.
Testing. Two layers, both cheap:
$response->assertSee('ApexCharts', false) is too weak — assert against a stored snapshot of the generated markup so a renderer or package upgrade that changes output fails loudly.it('renders the signups chart', function () {
$html = app(ChartRenderer::class)->render(SignupsPerDay::make()->data());
expect($html)->toMatchSnapshot();
});
Caching and cost control. Chart datasets are usually expensive queries with a forgiving freshness window. Cache the ChartData value object (not the HTML — cache the data so config/color changes still apply) for 5–15 minutes, keyed by tenant/range. This also smooths render latency on dashboards with many charts.
Multiple charts on one page. One Blade component per chart, each with its own small dataset. If a dashboard has more than ~5 charts, lazy-load below-the-fold charts (Livewire #[Lazy], or render on scroll). Client-side rendering cost scales with chart count and series length on the user's device, not your server.
Livewire re-render. If you use Livewire, the chart must re-initialize after a morph. The reliable pattern is a wrapper with wire:ignore, keys on the chart container tied to the data hash, and dispatching an event on update so your JS re-renders. Verify this in the pilot explicitly — it's the most common failure mode for JS-backed Blade components, and it does not show up in a static test.
Asset publish skew. If the package publishes JS to public/vendor/..., published assets go stale relative to the Composer package version and you get behavior matching neither release. Publish during deploy only, never by hand on a dev box, and treat public/vendor as build output (gitignore it, or at minimum assert it matches the installed package version in CI).
Renderer drift beats package upgrades. You're pinned to an ApexCharts version via a wrapper maintained by one person. A breaking major in ApexCharts will not be reflected until the maintainer ships. Pin ^1.0, keep the render snapshot test, and watch the bundled JS version — not the PHP version — for security advisories. Dependabot on the PHP repo does not cover the JS bundle.
CSP. Verify whether the package emits inline <script> initialization. If it does, you need a nonce or hash in your CSP, or you'll ship a dashboard that works in dev and silently blanks in production behind script-src. Check this in the first spike; retrofitting CSP into a charting layer is painful.
Config cache. config/charts.php changes do nothing until config:clear (or a deploy that rebuilds cache). Colors and defaults that "aren't applying" are almost always this.
Debugging a blank chart. In order: (1) view source and confirm the options object and series data are present, (2) dump() your ChartData before render to confirm non-empty, correctly-typed arrays, (3) check the browser console for ApexCharts initialization errors — usually a missing container ID or a re-mounted DOM node, (4) confirm the assets actually 200'd, (5) only then suspect the package. Nine times in ten it's typed data, CSP, or a Livewire morph.
Nulls and gaps. A null in a numeric series can shift the entire x-axis alignment or collapse the series. Decide up front whether a missing day is 0 or null (connectNulls) and encode that in your ChartData construction, not ad hoc per chart.
Dates and timezones. Aggregate in the application's timezone, format to fixed strings, and never rely on the browser to parse a date format it wasn't given. Off-by-one-day in daily charts is a timezone bug 100% of the time.
Extension points to prefer. Publishing and overriding the Blade view is the cleanest way to inject markup, analytics attributes, or CSP nonces without forking. A passthrough options array (if the builder exposes one) covers anything the fluent API doesn't, but keep those escapes in one place — a customOptions key on your ChartData — so they're greppable when ApexCharts changes.
Don't let chart config leak. The moment Chart::line()->colors([...]) appears in a controller or an API resource, the adapter is dead and swap cost becomes real. Enforce it with a code review rule or a static check banning ZaimeaLabs\Charts outside app/Charts/.
Ramp-up reality. First chart is half a day. Production-quality behind the adapter, with caching, a snapshot test, and CSP sorted, is one to two days — and the second day is the asset pipeline, not the package. Budget the pilot as two days.
How can I help you explore Laravel packages today?