Couple of weeks ago I shipped fastchart 0.2.0 and wrote it up here. One extension, 19 chart types, server-side rendering through ext/gd.
The idea was right, but execution not quite there.
After the launch I spent a few days actually looking at the output side by side with what plutovg can do on the same primitives. The libgd-rendered charts were fine for what libgd is, which is a 1990s 2D rasterizer with no subpixel-precise anti-aliasing. Diagonal lines were jaggy. Glyph edges grainy at small sizes. The output read as "a chart drawn by ext/gd in 2026" instead of "a chart." A couple of Reddit threads (r/PHP and r/laravel) pointed at the same thing without me having to ask.
So I rewrote it. fastchart 1.1.1 is the current stable of that rewrite, following a number of stabilization tweaks and refinements, the end result being SVG promoted to the canonical render format, and the chart-family count up from 19 to 26.
libgd's anti-aliasing is gdAntiAliased plus the alpha channel. It works for solid-fill regions. It falls apart on the boundary between two filled regions of different colors, on glyph rendering at body-text sizes, and on rotated text where the rotation axis isn't a 45° multiple.
Specific cases I kept hitting:
LineChart showed visible stair-stepping at 1px stroke width. libgd's gdImageLine with gdAntiAliased blends against the canvas background, not the local color, so a red line crossing a blue series region looked correct on the white margin and wrong inside the plot area.None of these are libgd bugs. They are libgd doing exactly what libgd has always done. They are also not things I can fix in a wrapper.
The shortest path from "I want sharper output" to "the output is sharper" is to stop rasterizing in the render path at all. SVG is text. The render path appends strings into a smart_str. The reader (a browser, an inkscape, a plutovg) does the rasterization at the resolution and pixel density it wants.
That solves the visual-quality complaint by default. It also gives back DPI-independent output: a chart embedded in a PDF or rendered at retina density on a Mac no longer needs setDpi(192) to look right.
Not everyone wants or needs SVG, so the architecture needs a path for traditional image formats too (PNG / JPG):
renderSvg() returns the document bytes.renderPng() / renderJpeg() / renderWebp() flatten the SVG's <text> elements to glyph outline paths via FreeType, run the result through plutovg + plutosvg (vendored, both MIT-licensed), and encode the rasterized RGBA buffer with libpng / libjpeg-turbo / libwebp.Same chart object, four render methods, four outputs. No second code path for rasters; the raster encoders sit downstream of the same vector builder.
The cost is that fastchart now links against four codec libs instead of one. The benefit is libgd is gone, both as a build dependency (no libgd-dev apt package required) and as a runtime requirement (the chart classes no longer touch GdImage at all).
The architectural pivot. SVG canonical, libgd dropped, plutovg + plutosvg vendored. Seven new chart families lifted the family count from 19 to 26: BulletChart, ParetoChart, CalendarHeatmap, SunburstChart, SankeyChart, MarimekkoChart, VectorChart. All seven were sitting in the same private-extensions queue as the 0.2.0 set; pulling them into the public repo was easier once the architecture was settled.
Breaking changes from 0.2.0: no more GIF support, and no more GdImage canvas input/output.
1.0.1 added prebuilt binaries via PIE for Windows (PHP 8.3 / 8.4 / 8.5, NTS + TS, x64 + x86) and Linux x86_64 / arm64 + macOS arm64 (PHP 8.4 / 8.5 NTS). 1.0.2 fixed a libjpeg soname mismatch that broke the Linux prebuilts on Debian-based PHP, including every official php:X.Y-cli Docker image. The fix statically links libjpeg-turbo from a configure-time source build, with -Wl,--exclude-libs=ALL so the static symbols stay local to fastchart.so and don't collide with the system ext/gd in the same process.
AreaChart::setBandMode(bool) for confidence-interval and min-max envelope fills. PolarChart::setInterpolation(INTERP_SMOOTH) for Catmull-Rom-subdivided polar curves. PolarChart::addVectors() to overlay arrow vectors in (angle, radius) data space. Funnel::setStyle(STYLE_CONE) for a pyramid with front-facing ellipse-arc edges. BubbleChart::setYAxisScale(SCALE_LOG). And Chart::setImageMap() + getImageMap() for HTML image-map hot-spots on BarChart, PieChart, and ScatterChart, with allowlisted URL schemes (http / https / mailto / / / #) and HTML-escaped tooltip text.
Shipped today. No new APIs, no breaking changes. Fourteen fixes to improve code quality. A non-exhaustive sample:
PolarChart::addVectors() rejects NaN / Inf at the boundary now, matching every other polar setter. The float-to-int cast it was feeding is UB per C11 §6.3.1.4p1.MarimekkoChart::setColumns() running-sum overflow. Each individual segment value was isfinite-guarded, but the per-column sum and cross-column total could overflow to +Inf from two segments at 1e308 each. Columns whose running sum is non-finite are now silently dropped.StockChart STYLE_VECTOR climax-deque ring-buffer overflow. After 11 strictly-decreasing pushes the deque tail wrapped to the head and every subsequent bar got misclassified as a climax. The stale-front drop now runs before the push.volatile across setjmp(). Per C99 §7.13.2.1 a register-spilled local that libjpeg's longjmp clobbers is UB; the cleanup branch could leak or double-free.fc_ft_measure() was a 1 / 2 / 3-byte UTF-8 decoder. Non-BMP codepoints (emoji, CJK extensions, mathematical alphanumeric symbols) contributed 0 to the measured width, so chart layout reservations came up systematically narrow on labels containing emoji. 4-byte branch added.setOhlcv timestamps near LLONG_MIN..LLONG_MAX overflowed signed zend_long in four call sites. Promoted to double arithmetic; out-of-range double-to-zend_long casts clamped.sprintf to snprintf, bilinear tile sampling) are now upstream. Preserved fastchart-only: the stb_image PNG palette zero-init and the STBI_ONLY_PNG / STBI_ONLY_JPEG cut that drops ~70% of the stb image-loader binary.Full list in CHANGELOG.md.
Along the way the 1.x cycle caught the attention of Remi Collet, who runs the rpms.remirepo.net PHP RPM repository (the standard PHP RPM source for RHEL / Fedora / CentOS). Three merged PRs landed between 1.1.0 and 1.1.1: a test-path correction so the suite finds the build directory regardless of out-of-tree builds, a fix for an undefined $ext_builddir that broke the Fedora PHP build glue, and an additional Fedora-family font-path probe (/usr/share/fonts/lato/, /usr/share/fonts/TTF/) for MINIT's default-font detection. The cumulative effect is that fastchart now builds and runs cleanly under Remi's RPM packaging.
Median in-memory render time at 1920×1080 on a single core, Intel i9-13950HX, PHP 8.4 debug build, default font and DPI. SVG is the canonical output; PNG / WebP / JPG add their rasterize + encode cost on top of the SVG build.
| Chart | SVG ms | PNG ms | WebP ms | JPG ms |
|---|---|---|---|---|
| AreaChart | 5.7 | 71.7 | 54.4 | 34.5 |
| BarChart | 11.0 | 75.8 | 57.0 | 38.8 |
| BoxPlot | 4.4 | 65.0 | 47.5 | 31.6 |
| BubbleChart | 2.6 | 90.9 | 62.7 | 38.6 |
| GaugeChart | 1.2 | 69.4 | 49.7 | 29.1 |
| LineChart | 5.4 | 74.2 | 55.5 | 34.3 |
| PieChart | 2.8 | 71.6 | 51.9 | 33.0 |
| StockChart | 8.4 | 80.8 | 63.3 | 41.0 |
SVG sits between 1.2 ms and 11 ms across all 19 measured types. There is no rasterizer in that path; the backend appends strings into a smart_str and returns the bytes. The raster encoders split into three bands: JPG fastest at 25 to 41 ms (libjpeg-turbo with 4:2:0 subsampling), WebP middle at 41 to 63 ms (libwebp with WEBP_PRESET_DRAWING + method=2 + multi-thread, roughly 2× faster than the simple API the rewrite started with), PNG slowest at 62 to 91 ms (libpng's deflate dominates). All four formats stay under 95 ms at 1080p on one thread.
Full table for all 19 measured chart types in the README perf section. Reproduce with php -d extension=./modules/fastchart.so docs/bench/bench.php.
The 0.2.0 differentiator was draw(GdImage $canvas): hand fastchart a gd canvas you owned, it drew into it, you composited multiple charts on one image. That API is gone in 1.x because v1.x owns its pixel buffer.
The replacement is drawSvgFragment() plus the static svgToPng / svgToJpeg / svgToWebp rasterizers:
$line = (new FastChart\LineChart(800, 600))
->setTitle('Daily active users')
->setSeries([['data' => $values]]);
$bar = (new FastChart\BarChart(800, 600))
->setTitle('Quarterly revenue')
->setSeries([['data' => $bars]]);
// Stitch two fragments into one outer SVG document.
$outer = <<<SVG
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="600" viewBox="0 0 1600 600">
<g transform="translate(0,0)">{$line->drawSvgFragment()}</g>
<g transform="translate(800,0)">{$bar->drawSvgFragment()}</g>
</svg>
SVG;
// Take the stitched SVG either as bytes or rasterized in one call.
file_put_contents('/tmp/dashboard.svg', $outer);
file_put_contents('/tmp/dashboard.png', FastChart\Chart::svgToPng($outer));
Same dashboard story as the 0.2.0 example, no shared canvas. The rasterizer sees the full composed document and antialiases across fragment boundaries cleanly.
On Linux with a standard fonts package installed (Ubuntu's fonts-lato, Debian's fonts-dejavu, etc.) fastchart's MINIT auto-probes a default. On macOS or in minimal containers, add ->setFontPath('/path/to/font.ttf') on each chart so labels render.
The SVG-canonical pipeline also gave a freebie: svgToPng / svgToJpeg / svgToWebp rasterize any caller-supplied SVG bytes through the same plutovg + encoder path, not just fastchart's own output. If you need to convert an arbitrary SVG to PNG / JPG / WebP from PHP without shelling out to ImageMagick, rsvg-convert, or Inkscape, that's now in-process. The caveat is text: plutosvg has no text engine, so <text> elements in caller-supplied SVG render blank. Either pre-flatten to <path> (Inkscape's "Object to Path" works) or accept the limitation. Hard caps apply: 16 MB input, output dimensions capped at 4096 px and 16 Mpx, embedded data:image/ URIs rejected (plutosvg's loader bypasses the dim cap on those), <use> elements rejected (the cycle detector doesn't count fan-out, so nested <g><use/>×10 can hit billion-laughs expansion). Contract at docs/specs/svg-to-raster.md.
Full SVG + PNG + JPG + WebP gallery for every chart family at iliaal.github.io/fastchart/v1-gallery.html. Source PHP shown above each row.
pie install iliaal/fastchart
Repo: github.com/iliaal/fastchart. BSD-3, PHP 8.3+, NTS or ZTS.
The 0.2.0 framing held: PHP doesn't need a Node sidecar for charts. The breadth held: 19 chart types covering Cartesian, financial, non-Cartesian, and specialised shapes is roughly the surface most dashboards need. The fluent OO API held. What didn't hold was libgd underneath it. libgd is fine for what libgd is; it isn't a 2026 chart rasterizer. A few releases later, fastchart isn't built on top of libgd anymore, and the output reads accordingly.