Making ExcelBook::save() atomic is easy to describe. Write the workbook to a temporary file, then rename it into place. A save that dies halfway leaves the original file untouched.

The obvious way to build that staged file is to ask LibXL for the finished archive as one buffer and write the buffer out. It works. It also costs 67.7 MB of peak RSS on a 3.3 MB workbook, and the PHP-side copy counts against memory_limit. The streaming writer php_excel used before the staging work costs 0.7 MB for the same file. That is roughly twenty times the workbook size in RAM, spent by a change whose whole purpose was safety.

That one arrived and was fixed inside the same release window, so no published version carries it. It is still a fair summary of the last two weeks. Nine of my PHP extensions released today. Between them they add one new method, one set of interfaces, and a few error constants. Everything else is a bug fixed, a value rejected, a limit enforced, or a cost paid back down.

The previous roundup ended on the claim that most of the work in these extensions is the unglamorous kind. This cycle is what comes after that claim, and it has a bill attached. Checking something on every row costs something on every row. Staging a write costs memory. Validating a wrapper at construction costs construction time. Most of the performance work below is repair rather than new optimization: finding where the safety checks got expensive, and making them cheap again.

php_excel 2.6.0: every save stages and renames

2.5.0 extended atomic save from stream wrappers to plain local paths, then restored the streaming writer underneath it. Local saves now stage through xlBookSave into an exclusively created sibling temp file and rename it into place, so the atomicity holds and the memory cost does not. Save time is unchanged. The staged file also carries the destination's existing permission bits, so replacing a 0600 workbook no longer widens it to 0644.

Atomicity on local paths changes three behaviours worth knowing before you upgrade. A symlink destination is replaced by a regular file instead of written through. An existing hard link is broken. Saving into a writable file inside a non-writable directory now fails instead of succeeding, because the rename needs the directory.

Typed writes stopped lying. An explicit data type that the value cannot satisfy is now rejected rather than silently stored as something else.

// 2.4.0: stored the string as text. 2.5.0: rejects, and names the cell it stopped at.
$sheet->write(0, 0, 'not a date', null, ExcelFormat::AS_DATE);

null still writes a blank cell under any data type, so bulk writeRow() and writeCol() calls over a typed column with gaps keep working.

2.6.0 is two lines on top of that, and one is breaking. ExcelFormat::borderDiagonalStyle() was wired to the diagonal direction rather than the line style it is named for. It now addresses the style, and the new ExcelFormat::borderDiagonal() gets and sets which diagonals are drawn. Code passing a BORDERDIAGONAL_* constant to the old method keeps running without error and quietly stops drawing its diagonal, so it is worth grepping for.

github.com/iliaal/php_excel

pdo_duckdb 0.5.0: the sandbox stopped charging by the row

The open_basedir sandbox re-latched itself once per row and compared the recorded basedir by hash. Under a long open_basedir that cost scaled with the string length, which is a strange thing for a per-row cost to depend on. It now re-latches once per fetched data chunk and compares by string.

A 400,000 row scan with a 223 character open_basedir went from 133 ns/row back to 51 ns/row. Bulk Appender::appendRow() went from 196 ns/row back to 96 ns/row.

The rest of the release is the sandbox itself getting harder to walk out of. DuckDB re-allowlists a non-empty temp_directory and any attached paths when external access is turned off, and those entries cannot be cleared afterwards, so the escalate sequence now clears temp, clears the sticky log_query_path and profiling_output writers, drops the path allowlists, and detaches out-of-basedir databases before flipping enable_external_access off and locking configuration. Re-narrowing open_basedir after that fails closed rather than leaving the wider policy in force.

One rendering bug is worth calling out because it is silent. A DECIMAL whose type has no integer digits rendered with a leading zero that DuckDB itself does not emit.

// DECIMAL(2,2): DuckDB renders .05, the driver rendered 0.05

That affected scalar cells since 0.4.1 and nested LIST, ARRAY, STRUCT, and MAP cells since the direct nested renderer landed. Prebuilt binaries now link DuckDB 1.5.5; a source build still works against the advertised 1.5.3 floor.

github.com/iliaal/pdo_duckdb

fast_uuid 0.6.0: a batch that reads the clock once

uuid_v7_batch() and uuid_v7_bin_batch() used to read the clock once per UUID. They now read it once per call, which is about 2.6x faster per UUID on aarch64. The v4 batch generators draw CSPRNG bytes for 64 UUIDs at a time instead of 16 bytes per UUID, worth another 6 to 8%.

$ids = uuid_v7_batch(100000);   // ~8 ms, all carrying the batch-start millisecond

The trade is stated in the release notes because it is observable: every UUID in one call carries the batch-start millisecond, so getDateTime() on the tail of a large batch can trail the wall clock by the batch duration. Values stay unique, sorted, and monotonic against the next single call.

The compat layer paid a cost in the other direction. Internal\ConstructionToken::Trusted used to skip the wrapper-class check, and it no longer does, so every compat wrapper validates its core on construction. That is one getVersion() call and a class-name comparison, and it makes construction about a third slower than 0.4.0: UuidFactory::fromBytes() drops from 1.81M to 1.21M ops/s on aarch64. I kept it. A wrapper that does not match its core is a bug that surfaces somewhere much less convenient.

0.6.0 also settled how far the compat layer follows ramsey/uuid. Every identity form now derives from toString(), as it does upstream, so getUrn(), getHex(), getInteger(), equals(), and compareTo() all follow a COMB codec's reshaped text rather than the network-order core. The one deliberate departure is GuidStringCodec, which reshapes the byte array only. Over 400 $guidFactory->uuid4() calls, ramsey/uuid 4.9.2 threw 190 times with "The byte string received does not contain a valid version" and emitted a wrong version nibble in the text 184 more times, leaving 26 clean. The same loop here returns 400 usable v4 GUIDs.

github.com/iliaal/fast_uuid

fastchart 1.7.0: limits, and one honest regression

Every documented resource and numeric limit is now enforced before valid chart state is replaced. Pie totals, graph-label aggregates, error-bar arrays, Venn sets, category labels, and scatter image-map strings used to validate after clearing, so a caught over-cap ValueError left you holding a wiped chart. Arc, Chord, and Network setNodes() and setLinks() throw on over-cap input instead of silently truncating it.

Two new INI settings bound memory that memory_limit never saw, because it lives outside the PHP allocator:

fastchart.max_render_pixels = 64M      ; PHP_INI_SYSTEM, bounds raster buffers
fastchart.max_image_cache_bytes = 64M  ; PHP_INI_SYSTEM, bounds retained decoded images

Caller-supplied SVG is now rejected above 65,536 elements, 262,144 attributes, or 256 nesting levels. renderToFile() accepts only local filesystem paths, because a stream wrapper cannot provide atomic replacement, and it preserves an existing destination's mode, revalidates open_basedir and staging identity at commit, and leaves the prior file under a recovery name when a post-install race prevents cleanup.

The performance line is mixed and I would rather write it down than round it off. Stock rolling indicators moved to linear-time extrema and statistics paths, and ordinary series are 2 to 33% faster. A stock series carrying a near-DBL_MAX outlier with a multi-thousand window costs about 4x the 1.6.0 setter time, because that series now goes through normalized sliding aggregates instead of an overflow-prone accumulator. Raster file output streams encoder bytes straight into the atomic destination, and in-memory raster output no longer keeps a second full-size pixel frame.

github.com/iliaal/fastchart

fastjson 0.7.0: ext/json parity, down to the error state

fastjson's promise is that you can swap it in for ext/json, and this release is mostly about the places where that wasn't quite true. fastjson_encode(), fastjson_file_encode(), and replacement encoding in fastjson_pointer_set() now publish the outer operation's final error state after a nested callback or destructor calls fastjson recursively. A decode of a string carrying both a backslash escape and invalid UTF-8 under JSON_INVALID_UTF8_IGNORE no longer rejects: "\/\xFFe" was reported as an unexpected control character and now decodes to /e, as json_decode() does.

fastjson_pointer_set() picked up three fixes that matter if you use it on documents you didn't write. It validates pointer syntax and traversal before serialising the replacement, so a malformed pointer no longer runs JsonSerializable callbacks first. It preserves untouched number lexemes byte for byte, so large integers, -0, and trailing-zero forms survive a set elsewhere in the document. And fastjson_pointer_get() now rejects a pointer whose traversed path is made ambiguous by duplicate object members rather than silently taking the first.

The performance work, measured against 0.6.0 on PHP 8.4 release builds, x86_64 unless the changelog notes aarch64:

Operation Change
fastjson_merge_patch(), 2,000-key merge ~95% faster
Large clean-ASCII encode, 256 KiB to 1 MiB 32 to 66% faster
Tolerant decode on clean input 20 to 64% faster
Packed-array decode ~22% faster
fastjson_pointer_set() 42 to 66% faster
Top-level scalar encode 35 to 43% faster

One of those is a reversal. The exact-size preflight for very large strings now starts at 8 MiB rather than 1 MiB, because below that the second pass cost more than the reservation it saved: 75% slower on x86_64 and 160% slower on aarch64 for UTF-8 text at 1 MiB. The optimization was real and the threshold was wrong.

github.com/iliaal/fastjson

phpser 0.5.0: a signature is not a provenance proof

The signed decode path in phpser assumed that a validly signed frame came from phpser's own encoder, and used that assumption to skip work. It skipped the object pin, and it used add_new style inserts for assoc, object-property, and rowset schema keys on the grounds that the encoder would never emit a duplicate.

A valid HMAC proves key possession, not honest-encoder provenance. Anyone holding the key can hand the decoder a frame the encoder would never produce. A forged-but-validly-signed frame carrying a duplicate key could free an id-registered object on overwrite while a later back-reference still pointed at it, which is a use-after-free reachable by anyone who can sign. Objects are now pinned unconditionally for the decode pass, and the trusted paths use uniqueness-gated inserts.

Two more use-after-frees came out of the encoder, both through user hooks. A __serialize or __sleep hook that grows the array being walked through a by-reference alias reallocated the table under the element iterator; the walk now holds a reference so the write copy-on-write separates instead. Native serialize() still exhibits this on the same input. A nested hook that replaced a referenced __sleep() member name after phpser had borrowed the string pointer is the third, and the encoder now owns the selected property names until it finishes the object. Decoder work on crafted Zend hash collisions is bounded, so wire-controlled keys cannot turn an associative array into quadratic CPU.

The columnar format got better at the same time. TAG_TABLE deduplicates low-cardinality strings and equal packed-string vectors by content, which makes distinct-allocation rowsets 53% smaller and 44% faster to decode than igbinary on ARM. Table decode streams each column into the row arrays instead of materializing the columnar matrix first: on a 60,000 by 4 table, peak memory drops from 34.0 MB to 30.0 MB with no speed change, and a rejected frame never pays for the rows it claims.

github.com/iliaal/phpser

php_clickhouse 0.11.0: values that came back different

These are the kind of bug you don't notice until a number is wrong in a report.

Float32 reads round-tripped through %.6g, which drops significant digits. toFloat32(123456789) read back as 123457000. Reads now return the exact value the server sent, with the full binary expansion, so toFloat32(0.55) is 0.550000011920929. Inserting a float outside the column's range throws instead of storing ±INF.

A compression setting of 2 was silently downgraded to LZ4 rather than selecting ZSTD. The option now accepts 0/1/2, the strings "none"/"lz4"/"zstd", and bool, and throws on anything else. Bool inserts accept only true/false, 0/1, and those four values as strings; the string "false" previously stored true.

Map decoding produced PHP arrays with duplicate keys, because the duplicate check went through zend_hash_str_add_new(), which skips the existence test and appends a second bucket under the same key instead of reporting the collision. Associative decoding now rejects a Map whose entries would collapse onto the same PHP array key, and a new mode gives you the lossless form:

$rows = $ch->select('SELECT m FROM t', [], ClickHouse::MAP_AS_PAIRS);  // ordered [key, value] pairs

ClickHouseStatement joins cycle collection, so a statement holding an object cell stops leaking its rows until request shutdown. A failed TLS handshake stops leaking the peer certificate and its chain, about 4.4 KB per rejected connection. And selectStream() works on a result carrying an untyped NULL column when memory_limit is set, which php-fpm sets by default. On 32-bit PHP, protocol integers and DateTime values wider than zend_long come back as decimal strings instead of wrapping negative.

github.com/iliaal/php_clickhouse

mdparser 0.5.0: less memory, same output

The output buffer used to reserve an exact size for every document. It now seeds a roughly 1.25x reserve capped at 1 MiB, so ordinary documents reallocate less, sparse input stops reserving output it never produces, and dense input past the cap grows by doubling. Heading-anchor side buffers are released as each heading is flushed instead of held for the document. Adjacent AST text fragments are coalesced into one text node, which cuts the callback-driven array overhead that showed up on AST-heavy workloads.

toInlineHtml() got the worst of the old behaviour. It appended multiline content one byte at a time, dropped line-leading inline delimiters, and let its per-line sentinel surface inside code and LaTeX spans that crossed a line break. It now bulk-appends content runs and preserves both the delimiters and literal U+200B.

Two GitHub Flavored Markdown gaps closed. Options::github() enables alerts as well as footnotes, and a bare URL containing a percent sign autolinks: http://a.com/x%20y used to stop the scan at the percent and stay literal text. Fenced code whose info string entity-decodes to a language- prefix no longer gets a second one prepended, so language-php produces class="language-php".

The vendored md4c moved to master 10c0158, which lets the local code-span line-break patch go away now that upstream carries it and picks up a bounds guard on the table-alignment dash scan. Zend memory-limit bailouts now release md4c's libc allocations before the request aborts.

github.com/iliaal/mdparser

phonetic 0.4.0: constants that cannot be confused

bmpm() takes a name type and an accuracy, in that order:

function bmpm(string $string, int $name_type = BMPM_GENERIC, int $accuracy = BMPM_APPROX, string $language = ""): string

BMPM_APPROX was 1 and BMPM_EXACT was 2. BMPM_ASHKENAZI is 1 and BMPM_SEPHARDIC is 2. So bmpm($name, BMPM_APPROX), which is a natural thing to write and wrong, ran as an Ashkenazi encode and returned a plausible answer. Nothing rejected it because nothing could tell the difference.

The accuracy constants are now 10 and 20, disjoint from the name-type range, so the same call is rejected by argument validation. This is breaking only for code that hard-codes the numeric values 1 and 2 for the accuracy argument; code using the constant names is unaffected. Forcing $language = "any" also raises a ValueError now, since that is the default ruleset label rather than a language; pass an empty string for auto-detect.

The rest is BMPM performance and bounds. The final-rule merge sorts once instead of running an O(R²) insert-sort, BMPM_MAX_PREFIX_DEPTH drops from 16 to 6 to bound the prefix dual-encode fan-out, and phoneme-set growth uses overflow-safe allocation. One earlier optimization got reverted: routing 1 to 3 element compares through memcmp() cost 5 to 6% of encode time, so the rule-matching inner loops compare code points inline again. double_metaphone() stops encoding once both codes reach max_length, and the match helpers skip the second encode when the first operand is unencodable or the two operands are identical.

github.com/iliaal/phonetic

What a stabilization release actually costs

Across these nine changelogs there is almost no new capability. One border method, one set of interfaces, a few error-code constants, one lossless Map mode. The rest of it is a Float32 that finally comes back the way the server sent it, a signed frame trusted for exactly what a signature proves and nothing further, and a bmpm() accuracy constant that can no longer be mistaken for a name type.

The part I didn't expect when I started this cycle is how much of the performance work turned out to be the same job. Streaming the workbook save back, re-latching the sandbox per chunk instead of per row, reading the clock once per batch, moving the string preflight threshold from 1 MiB to 8 MiB, comparing three code points inline instead of through memcmp(). None of that is a new algorithm. It is the cost of correctness, found and paid down after the correctness landed. Some of it stays paid: compat construction in fast_uuid is a third slower on purpose, and a fastchart stock series with a near-DBL_MAX outlier costs 4x what it did. Those are the trades I would make again, and they belong in the changelog rather than in the rounding.