ExcelBook::save() to a file:// path used to truncate your existing file before it finished writing the new one. If the write was short or got interrupted, you were left with neither the old file nor a complete new one. php_excel 2.4.0 fixes that: the write goes to a temp file and renames into place, so a failed save leaves your original intact.
That fix doesn't make a good changelog headline. It's also most of what shipped across seven of my PHP extensions in the last month. Some of it is new capability, a new Markdown engine, twelve more chart types, a columnar serializer, native JSON columns in ClickHouse. Most of it is the other kind of work: rejecting a bad value instead of storing it, throwing on the untrusted path instead of trusting it, failing a write cleanly instead of half-committing it. This is the second roundup in the series; the June one covered the version-floor drops. Here is what landed since.
mdparser swapped its entire parsing backend in 0.4.0. cmark-gfm is gone, replaced by md4c, a single-file streaming CommonMark parser compiled directly into the extension. The four render methods and the Options surface are unchanged; only the engine underneath is new. The swap brings clean CommonMark 0.31 conformance (652 of 652 spec examples) and drops the external cmark-gfm dependency entirely.
md4c also supports a set of dialects cmark-gfm did not, each opt-in and off by default: LaTeX math ($inline$ and $$block$$), wiki links ([[target]]), spoilers, ==highlight==, super and subscript, and GitHub-style admonition blocks (> [!NOTE]) added in 0.4.1. Each surfaces as its own node type in toXml() and toAst().
use MdParser\{Parser, Options};
$html = (new Parser(new Options(latexMath: true, wikiLinks: true)))->toHtml($source);
The hardening that came with the rewrite is the part I care about more. 0.4.2 fixed a stack buffer overflow in the CommonMark XML serializer: an ordered list with a wide start number could truncate the tag, embed a NUL byte, and at the widest input read past a fixed 64-byte stack buffer. 0.4.3 closed an alt="..." breakout where raw HTML in image alt text was not attribute-escaped under unsafe: true. And the in-stream heading-anchor rewrite resolved the 0.3.0 limitation where a raw <h1> and a Markdown # heading could fight over the same id.
One honest trade: md4c exposes no source positions, so sourcepos and a handful of old cmark-only options are accepted for compatibility but inert.
fastchart 1.4.0 added twelve chart classes in one release, taking the catalog from 26 types to 38. The new set is mostly hierarchy and relationship charts: dendrogram, partition (icicle), chord diagram, network graph, arc diagram, circle packing, Venn diagram, violin plot, population pyramid, word cloud, pictogram, and a serpentine timeline. The two hierarchy charts feed from one setHierarchy() call.
$chart = new FastChart\Dendrogram(900, 600);
$chart->setHierarchy($root);
$chart->renderToFile('tree.svg');
1.5.0 was the performance release. A 4096-point scatter with per-point colors renders about 6x faster now that SVG color handles resolve through a hash table instead of a linear scan; renderPng() is about 40% faster after switching from adaptive to a fixed UP row filter at effectively unchanged file size; and dense SVG documents emit about 40% faster through dedicated integer color formatters. 1.4.0 also added seven StockChart indicators (VWAP, ZigZag, ATR, CCI, Williams %R, Aroon, standard deviation).
The vector PDF backend from 1.3.0 is still a v1. This round's PDF work was hardening, fully transparent colors that used to paint opaque, band-gradient alpha that got discarded, rather than the gradient support it still lacks.
Three releases since 0.8.7. 0.8.8 added native JSON, Bool, and IPv4/IPv6 column read and write; a JSON insert takes a PHP array, object, or raw JSON string, and reads return the string or decode with JSON_AS_ARRAY / JSON_AS_OBJECT. 0.9.0 rewrote the decoder and insert-builder hot paths. 0.10.0 tightened correctness.
The perf work in 0.9.0 is the kind you only find with a profiler. Result decoding used to downcast each scalar column through dynamic_pointer_cast, an atomic refcount round-trip plus an RTTI walk; a callgrind attributed about 18% of decode instructions to that one cast. The integer, float, and string paths now use a direct static cast keyed on the column's type code. Numeric inserts build straight from the row-major input instead of transposing into a temporary PHP array first. Wide integer SELECTs decode about 25% faster; numeric-heavy inserts land 30 to 44% faster.
// FixedString payloads (IPv6, hash digests) read back at full width, trailing NULs kept
$rows = $ch->select('SELECT ip FROM sessions', [], ClickHouse::FIXEDSTRING_BINARY);
0.10.0's correctness pass is the fail-closed theme again: inserting PHP null into a non-Nullable JSON column now throws instead of silently storing an empty {}, and a clean server error (bad SQL, missing table) no longer reconnects and quietly discards your session's temp tables and SET settings.
github.com/iliaal/php_clickhouse
phpser's wire format v2 (0.3.0) added a columnar layout for homogeneous rowsets. Instead of writing each row's keys and values, TAG_TABLE writes the column schema once, then each column as a typed run: packed longs, doubles, strings, or mixed. For the array-of-uniform-rows shape that cache and queue payloads usually are, that drops the per-row key overhead entirely. v1 payloads still decode.
$blob = phpser_serialize($rows); // columnar automatically when rows share a schema
$rows = phpser_unserialize($blob);
0.4.0 made table encode a single row-major pass instead of an O(rows × cols²) per-column rescan (about 20% faster rowset encode on aarch64), and rowset decode about 24% faster by resolving dictionary strings against the engine's interned-string table. It also closed two issues on the untrusted decode path. A TAG_OBJECT_SLOTS payload now routes through __unserialize() when the class defines it, instead of installing raw property slots and running __wakeup(), closing an invariant-rebuild bypass. And a crafted __PHP_Incomplete_Class_Name property can no longer resurrect an attacker-chosen class name on a later decode.
The columnar path is automatic; you don't opt in. It only engages for homogeneous string-keyed rowsets, and a single row or a mismatched schema falls back to the general encoding.
The June roundup covered fastjson_pointer_get() and merge-patch. 0.5.0 completed the RFC 6901 surface with fastjson_pointer_set(), which sets a value by pointer and returns the re-serialized document, and fastjson_pointer_exists(), which distinguishes present-but-null from absent without materializing anything into PHP.
$updated = fastjson_pointer_set($json, '/user/email', 'new@example.com');
0.5.0 also started publishing prebuilt .so binaries for Linux glibc (x86_64, arm64) and macOS (arm64) on PHP 8.4 and 8.5 as release assets, so PIE installs the binary instead of source-building on those targets. 0.6.0 was hardening: a use-after-free when encoding an array that a nested JsonSerializable mutates through an aliasing reference (the array is now copied first, as ext/json does), and stack-exhausting nesting in pointer_set rejected up front instead of crashing.
2.3.0 added bulk read paths. readRange() pulls a rectangular block of cells into a 2D array in one call instead of per-cell PHP dispatch, readSparseRow() and readSparseCol() return only the occupied cells keyed by their original index, and loadPartially() reads a single sheet's row slice without loading the rest of the workbook.
$block = $sheet->readRange(0, 999, 0, 25); // a 1000 x 26 region in one call
2.4.0 is the atomic-save fix from the top of this post plus the rest of the fail-closed pass: load() rejects a buffer above UINT_MAX instead of truncating a payload over 4 GiB, writeRow() and writeCol() validate every value before writing any cell instead of committing half the row and then failing, and inverted ranges (first > last) are rejected instead of stored as garbage coordinates. One security tightening: excel.license_key and excel.license_name are now PHP_INI_SYSTEM, so a per-request ini_set() or .user.ini can no longer overwrite the commercial license in a shared pool.
0.3.0 added binary and batch generation. The binary generators (uuid_v4_bin(), uuid_v7_bin(), and the rest) return the raw 16 bytes with no canonical formatting, for a BINARY(16) column or a wire format with no string round-trip. The batch generators build many at once and amortize the per-call cost; v7 batches stay monotonic within the call.
$ids = uuid_v7_bin_batch(1000); // 1000 raw 16-byte v7 UUIDs, monotonic
Most of 0.3.0 and 0.4.0, though, is ramsey/uuid compatibility correctness. The compat TimestampFirstCombCodec used a byte rotation instead of ramsey/uuid's first-6/last-6 swap, so a COMB written by ramsey/uuid decoded to a different UUID; the layout now matches, and COMB columns interop. Fields::getTimestamp() truncated v1 and v6 timestamps on 32-bit PHP; it now rebuilds them exactly as a hex string. Canonical parsing also runs about twice as fast through a 256-entry nibble lookup table.
One breaking change to note if you catch it: UnsupportedOperationException now extends \LogicException instead of \RuntimeException, matching ramsey/uuid 4.x.
The features are the headline: a new Markdown engine, twelve more chart types, a columnar serializer, native JSON in ClickHouse. But count the lines in these seven changelogs and most of them are the other kind: a value rejected instead of stored, a write that fails cleanly instead of half-committing, a throw on the untrusted path instead of a silent coercion. That work never reads well in release notes. It's the difference between an extension you benchmark once and one you leave running.