Most libraries raise their minimum PHP version over time. Drop 8.1, require 8.2, then 8.3, because every release you can assume lets you delete a pile of compatibility shims. This round I went the other way. php_excel, fastchart, and fastjson now build on PHP 8.1, phpser dropped to 8.2, and all four had required 8.3 a release ago. php_clickhouse already runs on everything from 7.4 up, so it sat this one out.

The reason is mundane but worth stating: for a native extension, the minimum PHP version is a packaging decision, not a language-feature decision. None of these extensions needed an 8.3-only engine API. The floor was set high because that was the version I built and tested against first, and lowering it just meant wiring the older versions into CI and fixing whatever broke. A shop pinned to 8.1 on a long-term-support distro gets the same speedups as one on 8.5. That's the whole point of shipping a C extension instead of a Composer package.

Here is what else landed since the last set of releases.

php_excel 2.2.0: a write mode that can't be tricked into a formula

php_excel 2.2.0 lowers its floor to PHP 8.1 and adds a cell write mode that makes spreadsheet formula injection something you opt out of by accident rather than into. The headline is ExcelFormat::AS_TEXT.

Write a user-controlled string that starts with =, and php_excel promotes it to a live formula, the same trick behind spreadsheet-injection attacks once the file is opened. The usual mitigation is to remember to prefix every untrusted value with a quote. AS_TEXT writes the value verbatim instead: no leading-quote stripping, no implicit formula promotion, no numeric coercion.

// Untrusted input written exactly as given, never evaluated as a formula
$sheet->write($row, 0, $userInput, null, ExcelFormat::AS_TEXT);

The implicit "leading = becomes a formula" promotion also got narrowed in 2.2.0. It now fires only when you pass no data type at all. If you explicitly pass a type such as AS_NUMERIC_STRING, that type is honored instead of being silently overridden.

The release before it, 2.1.0, added libxl 5.2.0 support, which surfaces the data validations stored in an xlsx file. ExcelSheet::dataValidationSize() returns the count and ExcelSheet::dataValidation($index) reads one back as an associative array, so you can inspect the dropdowns and constraints a file was authored with rather than only writing new ones. The rest of 2.2.0 is a long list of boundary fixes: named-range and autofilter methods whose declared parameter order did not match the implementation, so named-argument callers got rows and columns crossed, are now correct.

github.com/iliaal/php_excel

php_clickhouse 0.8.7: stream a file straight into ClickHouse

php_clickhouse picked up a streaming bulk loader in 0.8.5 and a large memory-safety pass in 0.8.6, with 0.8.7 restoring the PHP 8.3 Windows builds. The loader is the feature most people will reach for.

insertFromStream() parses a TSV or CSV file, or any readable PHP stream, and INSERTs it in batches without ever materializing the whole file in PHP memory. The parser is C++ and handles TSV escapes and RFC 4180 CSV quoting, including embedded quotes, commas, and CRLF inside quoted fields.

$ch->insertFromStream(
    'events',
    ['ts', 'user_id', 'event'],
    fopen('events.tsv', 'r'),
    'TSV',
    50000          // rows per batch
);

The 0.8.6 hardening pass is less visible but matters more if you run this in production. A Map column read could leave the result row holding a freed array, a use-after-free that crashed when the row was later iterated or json_encoded. Cloning a ClickHouse object corrupted the heap; it now throws instead. And setDatabase() now rebuilds the connection with the new default, so the switch survives a reconnect rather than silently reverting to the database you passed the constructor. Several of those were the kind of bug that only shows up under a specific reconnect-or-retry sequence, which is exactly when you least want a dangling pointer.

github.com/iliaal/php_clickhouse

fastchart 1.3.0: vector PDF output

fastchart 1.3.0 can render any of its chart types to a vector PDF, and 1.2.0 lowered the floor to PHP 8.1 and exposed structured hot-spot data for image maps.

PDF output goes through the same primitive layer as the SVG path, so every chart type renders as real vector geometry with no rasterization. Text flattens to glyph outlines; arcs and ellipses approximate with cubic Béziers. It's opt-in at build time with --with-pdfio, which links a system pdfio statically, so the runtime dependency set does not change. Without the flag the PDF methods throw and the PDF tests skip.

$chart->renderToFile('report.pdf');   // or: $pdf = $chart->renderPdf();

This is a v1, and I'll state the limitations rather than hide them: gradient fills fall back to solid, raster background images are dropped, and alpha is ignored so fills render opaque. For the report-and-dashboard use case that PDF output exists for, that's usually fine; if you need gradients today, render to PNG.

The other 1.2.0 addition is getImageMapAreas(), which returns the chart's clickable regions as structured data (shape, coordinates, href, tooltip, index) instead of the pre-baked HTML that getImageMap() emits. If you build your own overlays or generate links yourself, you no longer have to parse HTML to get the geometry back.

github.com/iliaal/fastchart

fastjson 0.4.0: JSON Pointer, merge-patch, and a relaxed decode mode

fastjson 0.4.0 grew document-surgery functions, a tolerant decode mode for config files, and an 8.1 floor. The two RFC functions are the interesting part because they avoid the full decode-and-re-encode round trip.

fastjson_pointer_get() reads a single value out of a JSON document by RFC 6901 pointer, materializing only the subtree you asked for instead of the whole thing into PHP.

$email = fastjson_pointer_get($json, '/users/0/email');

fastjson_merge_patch() applies an RFC 7386 merge patch and returns the merged document, recursing into objects and treating a null member as a delete. Both keep fastjson's json_last_error-compatible error reporting, so a parse failure sets the error state or throws under JSON_THROW_ON_ERROR, matching the native functions.

The second addition is FASTJSON_DECODE_RELAXED, which decodes the JSONC subset that ext/json rejects: line and block comments, trailing commas, and a leading BOM. It is backed by yyjson's own read flags rather than a pre-pass scrubber, so well-formed JSON decodes identically with or without it. Useful for hand-edited config files where you want comments without giving up the speed.

$cfg = fastjson_decode($jsonc, true, 512, FASTJSON_DECODE_RELAXED);

0.4.0 also added fastjson_file_decode() and fastjson_file_encode(), which collapse the read-then-decode and encode-then-write patterns into one call that still goes through the PHP streams layer, so stream wrappers and open_basedir apply exactly as they do for file_get_contents().

github.com/iliaal/fastjson

phpser 0.2.0: a faster decoder and a closed key-smuggling gap

phpser 0.2.0 lowers its floor to PHP 8.2, makes object decode meaningfully faster, and fixes a correctness bug in the untrusted decode path that is worth understanding even if you never hit it.

The decoder now resolves dictionary strings against the engine's interned-string table, so interned hits skip the per-slot allocation and all the refcount traffic. Objects go a step further: declared properties install straight into their property slots via ce->properties_info instead of building a properties HashTable per object. On the DTO-batch shape that Laravel queue and cache payloads actually look like, decode came out about 22 to 25 percent faster, and the decoded objects no longer carry a materialized table they didn't need.

Shape Decode before Decode after Delta
DTO batch (same-class) baseline property-slot install ~22-25% faster
Rowset baseline interned-string reuse ~9% faster

Measured against phpser 0.1.x on arm64, median of 12 runs.

The correctness fix: a crafted payload with a canonical numeric string array key, "5", now decodes to the integer key 5, matching native unserialize() and every array write PHP does. The untrusted path previously preserved it as a string key, a HashTable state no PHP code can actually produce. That let an attacker smuggle a value past isset() and array_key_exists() checks that assumed the key had already been coerced to an integer. The HMAC-signed path was never affected, because the encoder only ever emits integer keys for numeric strings, but the unsigned path is exactly the one you would point at untrusted storage, so it mattered.

github.com/iliaal/phpser

A native extension only earns its place by being installable, so this round put as much work into reach and safety as into raw speed.