<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged php-extension</title>
<link>https://coderlegion.com/tag/php-extension</link>
<description>Powered by Question2Answer</description>
<item>
<title>The Cost of Failing Closed: What Shipped Across Nine PHP Extensions</title>
<link>https://coderlegion.com/23606/the-cost-of-failing-closed-what-shipped-across-nine-php-extensions</link>
<description>&lt;p&gt;Making &lt;code&gt;ExcelBook::save()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;memory_limit&lt;/code&gt;. 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://ilia.ws/blog/failing-closed-what-shipped-across-seven-php-extensions&quot;&gt;previous roundup&lt;/a&gt; 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.&lt;/p&gt;
&lt;h3 id=&quot;php-excel-2-6-0-every-save-stages-and-renames&quot;&gt;php_excel 2.6.0: every save stages and renames&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;xlBookSave&lt;/code&gt; 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 &lt;code&gt;0600&lt;/code&gt; workbook no longer widens it to &lt;code&gt;0644&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Typed writes stopped lying. An explicit data type that the value cannot satisfy is now rejected rather than silently stored as something else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// 2.4.0: stored the string as text. 2.5.0: rejects, and names the cell it stopped at.
$sheet-&amp;gt;write(0, 0, 'not a date', null, ExcelFormat::AS_DATE);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;null&lt;/code&gt; still writes a blank cell under any data type, so bulk &lt;code&gt;writeRow()&lt;/code&gt; and &lt;code&gt;writeCol()&lt;/code&gt; calls over a typed column with gaps keep working.&lt;/p&gt;
&lt;p&gt;2.6.0 is two lines on top of that, and one is breaking. &lt;code&gt;ExcelFormat::borderDiagonalStyle()&lt;/code&gt; was wired to the diagonal direction rather than the line style it is named for. It now addresses the style, and the new &lt;code&gt;ExcelFormat::borderDiagonal()&lt;/code&gt; gets and sets which diagonals are drawn. Code passing a &lt;code&gt;BORDERDIAGONAL_*&lt;/code&gt; constant to the old method keeps running without error and quietly stops drawing its diagonal, so it is worth grepping for.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_excel&quot;&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;pdo-duckdb-0-5-0-the-sandbox-stopped-charging-by-the-row&quot;&gt;pdo_duckdb 0.5.0: the sandbox stopped charging by the row&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;open_basedir&lt;/code&gt; sandbox re-latched itself once per row and compared the recorded basedir by hash. Under a long &lt;code&gt;open_basedir&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;A 400,000 row scan with a 223 character &lt;code&gt;open_basedir&lt;/code&gt; went from 133 ns/row back to 51 ns/row. Bulk &lt;code&gt;Appender::appendRow()&lt;/code&gt; went from 196 ns/row back to 96 ns/row.&lt;/p&gt;
&lt;p&gt;The rest of the release is the sandbox itself getting harder to walk out of. DuckDB re-allowlists a non-empty &lt;code&gt;temp_directory&lt;/code&gt; 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 &lt;code&gt;log_query_path&lt;/code&gt; and &lt;code&gt;profiling_output&lt;/code&gt; writers, drops the path allowlists, and detaches out-of-basedir databases before flipping &lt;code&gt;enable_external_access&lt;/code&gt; off and locking configuration. Re-narrowing &lt;code&gt;open_basedir&lt;/code&gt; after that fails closed rather than leaving the wider policy in force.&lt;/p&gt;
&lt;p&gt;One rendering bug is worth calling out because it is silent. A &lt;code&gt;DECIMAL&lt;/code&gt; whose type has no integer digits rendered with a leading zero that DuckDB itself does not emit.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// DECIMAL(2,2): DuckDB renders .05, the driver rendered 0.05
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That affected scalar cells since 0.4.1 and nested &lt;code&gt;LIST&lt;/code&gt;, &lt;code&gt;ARRAY&lt;/code&gt;, &lt;code&gt;STRUCT&lt;/code&gt;, and &lt;code&gt;MAP&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/pdo_duckdb&quot;&gt;github.com/iliaal/pdo_duckdb&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fast-uuid-0-6-0-a-batch-that-reads-the-clock-once&quot;&gt;fast_uuid 0.6.0: a batch that reads the clock once&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;uuid_v7_batch()&lt;/code&gt; and &lt;code&gt;uuid_v7_bin_batch()&lt;/code&gt; 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%.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$ids = uuid_v7_batch(100000);   // ~8 ms, all carrying the batch-start millisecond
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trade is stated in the release notes because it is observable: every UUID in one call carries the batch-start millisecond, so &lt;code&gt;getDateTime()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;The compat layer paid a cost in the other direction. &lt;code&gt;Internal\ConstructionToken::Trusted&lt;/code&gt; used to skip the wrapper-class check, and it no longer does, so every compat wrapper validates its core on construction. That is one &lt;code&gt;getVersion()&lt;/code&gt; call and a class-name comparison, and it makes construction about a third slower than 0.4.0: &lt;code&gt;UuidFactory::fromBytes()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;0.6.0 also settled how far the compat layer follows &lt;code&gt;ramsey/uuid&lt;/code&gt;. Every identity form now derives from &lt;code&gt;toString()&lt;/code&gt;, as it does upstream, so &lt;code&gt;getUrn()&lt;/code&gt;, &lt;code&gt;getHex()&lt;/code&gt;, &lt;code&gt;getInteger()&lt;/code&gt;, &lt;code&gt;equals()&lt;/code&gt;, and &lt;code&gt;compareTo()&lt;/code&gt; all follow a COMB codec's reshaped text rather than the network-order core. The one deliberate departure is &lt;code&gt;GuidStringCodec&lt;/code&gt;, which reshapes the byte array only. Over 400 &lt;code&gt;$guidFactory-&amp;gt;uuid4()&lt;/code&gt; calls, &lt;code&gt;ramsey/uuid&lt;/code&gt; 4.9.2 threw 190 times with &quot;The byte string received does not contain a valid version&quot; and emitted a wrong version nibble in the text 184 more times, leaving 26 clean. The same loop here returns 400 usable v4 GUIDs.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fast_uuid&quot;&gt;github.com/iliaal/fast_uuid&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastchart-1-7-0-limits-and-one-honest-regression&quot;&gt;fastchart 1.7.0: limits, and one honest regression&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;ValueError&lt;/code&gt; left you holding a wiped chart. Arc, Chord, and Network &lt;code&gt;setNodes()&lt;/code&gt; and &lt;code&gt;setLinks()&lt;/code&gt; throw on over-cap input instead of silently truncating it.&lt;/p&gt;
&lt;p&gt;Two new INI settings bound memory that &lt;code&gt;memory_limit&lt;/code&gt; never saw, because it lives outside the PHP allocator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;fastchart.max_render_pixels = 64M      ; PHP_INI_SYSTEM, bounds raster buffers
fastchart.max_image_cache_bytes = 64M  ; PHP_INI_SYSTEM, bounds retained decoded images
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Caller-supplied SVG is now rejected above 65,536 elements, 262,144 attributes, or 256 nesting levels. &lt;code&gt;renderToFile()&lt;/code&gt; accepts only local filesystem paths, because a stream wrapper cannot provide atomic replacement, and it preserves an existing destination's mode, revalidates &lt;code&gt;open_basedir&lt;/code&gt; and staging identity at commit, and leaves the prior file under a recovery name when a post-install race prevents cleanup.&lt;/p&gt;
&lt;p&gt;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-&lt;code&gt;DBL_MAX&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastchart&quot;&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastjson-0-7-0-ext-json-parity-down-to-the-error-state&quot;&gt;fastjson 0.7.0: ext/json parity, down to the error state&lt;/h3&gt;
&lt;p&gt;fastjson's promise is that you can swap it in for &lt;code&gt;ext/json&lt;/code&gt;, and this release is mostly about the places where that wasn't quite true. &lt;code&gt;fastjson_encode()&lt;/code&gt;, &lt;code&gt;fastjson_file_encode()&lt;/code&gt;, and replacement encoding in &lt;code&gt;fastjson_pointer_set()&lt;/code&gt; 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 &lt;code&gt;JSON_INVALID_UTF8_IGNORE&lt;/code&gt; no longer rejects: &lt;code&gt;&quot;\/\xFFe&quot;&lt;/code&gt; was reported as an unexpected control character and now decodes to &lt;code&gt;/e&lt;/code&gt;, as &lt;code&gt;json_decode()&lt;/code&gt; does.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fastjson_pointer_set()&lt;/code&gt; 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 &lt;code&gt;JsonSerializable&lt;/code&gt; callbacks first. It preserves untouched number lexemes byte for byte, so large integers, &lt;code&gt;-0&lt;/code&gt;, and trailing-zero forms survive a set elsewhere in the document. And &lt;code&gt;fastjson_pointer_get()&lt;/code&gt; now rejects a pointer whose traversed path is made ambiguous by duplicate object members rather than silently taking the first.&lt;/p&gt;
&lt;p&gt;The performance work, measured against 0.6.0 on PHP 8.4 release builds, x86_64 unless the changelog notes aarch64:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;fastjson_merge_patch()&lt;/code&gt;, 2,000-key merge: ~95% faster&lt;/li&gt;
&lt;li&gt;Large clean-ASCII encode, 256 KiB to 1 MiB: 32 to 66% faster&lt;/li&gt;
&lt;li&gt;Tolerant decode on clean input: 20 to 64% faster&lt;/li&gt;
&lt;li&gt;Packed-array decode: ~22% faster&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fastjson_pointer_set()&lt;/code&gt;: 42 to 66% faster&lt;/li&gt;
&lt;li&gt;Top-level scalar encode: 35 to 43% faster&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastjson&quot;&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;phpser-0-5-0-a-signature-is-not-a-provenance-proof&quot;&gt;phpser 0.5.0: a signature is not a provenance proof&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;add_new&lt;/code&gt; style inserts for assoc, object-property, and rowset schema keys on the grounds that the encoder would never emit a duplicate.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Two more use-after-frees came out of the encoder, both through user hooks. A &lt;code&gt;__serialize&lt;/code&gt; or &lt;code&gt;__sleep&lt;/code&gt; 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 &lt;code&gt;serialize()&lt;/code&gt; still exhibits this on the same input. A nested hook that replaced a referenced &lt;code&gt;__sleep()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;The columnar format got better at the same time. &lt;code&gt;TAG_TABLE&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser&quot;&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;php-clickhouse-0-11-0-values-that-came-back-different&quot;&gt;php_clickhouse 0.11.0: values that came back different&lt;/h3&gt;
&lt;p&gt;These are the kind of bug you don't notice until a number is wrong in a report.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Float32&lt;/code&gt; reads round-tripped through &lt;code&gt;%.6g&lt;/code&gt;, which drops significant digits. &lt;code&gt;toFloat32(123456789)&lt;/code&gt; read back as &lt;code&gt;123457000&lt;/code&gt;. Reads now return the exact value the server sent, with the full binary expansion, so &lt;code&gt;toFloat32(0.55)&lt;/code&gt; is &lt;code&gt;0.550000011920929&lt;/code&gt;. Inserting a float outside the column's range throws instead of storing &lt;code&gt;±INF&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;compression&lt;/code&gt; setting of &lt;code&gt;2&lt;/code&gt; was silently downgraded to LZ4 rather than selecting ZSTD. The option now accepts &lt;code&gt;0&lt;/code&gt;/&lt;code&gt;1&lt;/code&gt;/&lt;code&gt;2&lt;/code&gt;, the strings &lt;code&gt;&quot;none&quot;&lt;/code&gt;/&lt;code&gt;&quot;lz4&quot;&lt;/code&gt;/&lt;code&gt;&quot;zstd&quot;&lt;/code&gt;, and bool, and throws on anything else. &lt;code&gt;Bool&lt;/code&gt; inserts accept only &lt;code&gt;true&lt;/code&gt;/&lt;code&gt;false&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;/&lt;code&gt;1&lt;/code&gt;, and those four values as strings; the string &lt;code&gt;&quot;false&quot;&lt;/code&gt; previously stored &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Map&lt;/code&gt; decoding produced PHP arrays with duplicate keys, because the duplicate check went through &lt;code&gt;zend_hash_str_add_new()&lt;/code&gt;, which skips the existence test and appends a second bucket under the same key instead of reporting the collision. Associative decoding now rejects a &lt;code&gt;Map&lt;/code&gt; whose entries would collapse onto the same PHP array key, and a new mode gives you the lossless form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$rows = $ch-&amp;gt;select('SELECT m FROM t', [], ClickHouse::MAP_AS_PAIRS);  // ordered [key, value] pairs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ClickHouseStatement&lt;/code&gt; 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 &lt;code&gt;selectStream()&lt;/code&gt; works on a result carrying an untyped &lt;code&gt;NULL&lt;/code&gt; column when &lt;code&gt;memory_limit&lt;/code&gt; is set, which php-fpm sets by default. On 32-bit PHP, protocol integers and DateTime values wider than &lt;code&gt;zend_long&lt;/code&gt; come back as decimal strings instead of wrapping negative.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse&quot;&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;mdparser-0-5-0-less-memory-same-output&quot;&gt;mdparser 0.5.0: less memory, same output&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;text&lt;/code&gt; node, which cuts the callback-driven array overhead that showed up on AST-heavy workloads.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;toInlineHtml()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;Two GitHub Flavored Markdown gaps closed. &lt;code&gt;Options::github()&lt;/code&gt; enables alerts as well as footnotes, and a bare URL containing a percent sign autolinks: &lt;code&gt;http://a.com/x%20y&lt;/code&gt; used to stop the scan at the percent and stay literal text. Fenced code whose info string entity-decodes to a &lt;code&gt;language-&lt;/code&gt; prefix no longer gets a second one prepended, so &lt;code&gt;language&amp;amp;#45;php&lt;/code&gt; produces &lt;code&gt;class=&quot;language-php&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The vendored md4c moved to master &lt;code&gt;10c0158&lt;/code&gt;, 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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/mdparser&quot;&gt;github.com/iliaal/mdparser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;phonetic-0-4-0-constants-that-cannot-be-confused&quot;&gt;phonetic 0.4.0: constants that cannot be confused&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;bmpm()&lt;/code&gt; takes a name type and an accuracy, in that order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;function bmpm(string $string, int $name_type = BMPM_GENERIC, int $accuracy = BMPM_APPROX, string $language = &quot;&quot;): string
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;BMPM_APPROX&lt;/code&gt; was &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;BMPM_EXACT&lt;/code&gt; was &lt;code&gt;2&lt;/code&gt;. &lt;code&gt;BMPM_ASHKENAZI&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;BMPM_SEPHARDIC&lt;/code&gt; is &lt;code&gt;2&lt;/code&gt;. So &lt;code&gt;bmpm($name, BMPM_APPROX)&lt;/code&gt;, 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.&lt;/p&gt;
&lt;p&gt;The accuracy constants are now &lt;code&gt;10&lt;/code&gt; and &lt;code&gt;20&lt;/code&gt;, 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 &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;2&lt;/code&gt; for the accuracy argument; code using the constant names is unaffected. Forcing &lt;code&gt;$language = &quot;any&quot;&lt;/code&gt; also raises a &lt;code&gt;ValueError&lt;/code&gt; now, since that is the default ruleset label rather than a language; pass an empty string for auto-detect.&lt;/p&gt;
&lt;p&gt;The rest is BMPM performance and bounds. The final-rule merge sorts once instead of running an O(R²) insert-sort, &lt;code&gt;BMPM_MAX_PREFIX_DEPTH&lt;/code&gt; 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 &lt;code&gt;memcmp()&lt;/code&gt; cost 5 to 6% of encode time, so the rule-matching inner loops compare code points inline again. &lt;code&gt;double_metaphone()&lt;/code&gt; stops encoding once both codes reach &lt;code&gt;max_length&lt;/code&gt;, and the match helpers skip the second encode when the first operand is unencodable or the two operands are identical.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phonetic&quot;&gt;github.com/iliaal/phonetic&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-a-stabilization-release-actually-costs&quot;&gt;What a stabilization release actually costs&lt;/h3&gt;
&lt;p&gt;Across these nine changelogs there is almost no new capability. One border method, one set of interfaces, a few error-code constants, one lossless &lt;code&gt;Map&lt;/code&gt; mode. The rest of it is a &lt;code&gt;Float32&lt;/code&gt; 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 &lt;code&gt;bmpm()&lt;/code&gt; accuracy constant that can no longer be mistaken for a name type.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;memcmp()&lt;/code&gt;. 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-&lt;code&gt;DBL_MAX&lt;/code&gt; 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.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23606/the-cost-of-failing-closed-what-shipped-across-nine-php-extensions</guid>
<pubDate>Mon, 27 Jul 2026 19:19:31 +0000</pubDate>
</item>
<item>
<title>Failing Closed: What Shipped Across Seven PHP Extensions</title>
<link>https://coderlegion.com/22216/failing-closed-what-shipped-across-seven-php-extensions</link>
<description>&lt;p&gt;&lt;code&gt;ExcelBook::save()&lt;/code&gt; to a &lt;code&gt;file://&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;a rel=&quot;nofollow&quot; href=&quot;https://ilia.ws/blog/lowering-the-php-floor-what-shipped-across-five-extensions&quot;&gt;June one&lt;/a&gt; covered the version-floor drops. Here is what landed since.&lt;/p&gt;
&lt;h3 id=&quot;mdparser-0-4-3-a-new-engine-under-the-same-api&quot;&gt;mdparser 0.4.3: a new engine under the same API&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;Options&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;md4c also supports a set of dialects cmark-gfm did not, each opt-in and off by default: LaTeX math (&lt;code&gt;$inline$&lt;/code&gt; and &lt;code&gt;\(block\)&lt;/code&gt;), wiki links (&lt;code&gt;[[target]]&lt;/code&gt;), spoilers, &lt;code&gt;==highlight==&lt;/code&gt;, super and subscript, and GitHub-style admonition blocks (&lt;code&gt;&amp;gt; [!NOTE]&lt;/code&gt;) added in 0.4.1. Each surfaces as its own node type in &lt;code&gt;toXml()&lt;/code&gt; and &lt;code&gt;toAst()&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;use MdParser\{Parser, Options};

$html = (new Parser(new Options(latexMath: true, wikiLinks: true)))-&amp;gt;toHtml($source);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 &lt;code&gt;alt=&quot;...&quot;&lt;/code&gt; breakout where raw HTML in image alt text was not attribute-escaped under &lt;code&gt;unsafe: true&lt;/code&gt;. And the in-stream heading-anchor rewrite resolved the 0.3.0 limitation where a raw &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; and a Markdown &lt;code&gt;# heading&lt;/code&gt; could fight over the same &lt;code&gt;id&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;One honest trade: md4c exposes no source positions, so &lt;code&gt;sourcepos&lt;/code&gt; and a handful of old cmark-only options are accepted for compatibility but inert.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/mdparser&quot;&gt;github.com/iliaal/mdparser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastchart-1-6-0-from-26-chart-types-to-38&quot;&gt;fastchart 1.6.0: from 26 chart types to 38&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;setHierarchy()&lt;/code&gt; call.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$chart = new FastChart\Dendrogram(900, 600);
$chart-&amp;gt;setHierarchy($root);
$chart-&amp;gt;renderToFile('tree.svg');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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; &lt;code&gt;renderPng()&lt;/code&gt; 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 &lt;code&gt;StockChart&lt;/code&gt; indicators (VWAP, ZigZag, ATR, CCI, Williams %R, Aroon, standard deviation).&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastchart&quot;&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;php-clickhouse-0-10-0-json-bool-and-ipv6-columns-and-a-faster-decoder&quot;&gt;php_clickhouse 0.10.0: JSON, Bool, and IPv6 columns, and a faster decoder&lt;/h3&gt;
&lt;p&gt;Three releases since 0.8.7. 0.8.8 added native &lt;code&gt;JSON&lt;/code&gt;, &lt;code&gt;Bool&lt;/code&gt;, and &lt;code&gt;IPv4&lt;/code&gt;/&lt;code&gt;IPv6&lt;/code&gt; column read and write; a &lt;code&gt;JSON&lt;/code&gt; insert takes a PHP array, object, or raw JSON string, and reads return the string or decode with &lt;code&gt;JSON_AS_ARRAY&lt;/code&gt; / &lt;code&gt;JSON_AS_OBJECT&lt;/code&gt;. 0.9.0 rewrote the decoder and insert-builder hot paths. 0.10.0 tightened correctness.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;dynamic_pointer_cast&lt;/code&gt;, 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 &lt;code&gt;SELECT&lt;/code&gt;s decode about 25% faster; numeric-heavy inserts land 30 to 44% faster.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// FixedString payloads (IPv6, hash digests) read back at full width, trailing NULs kept
$rows = $ch-&amp;gt;select('SELECT ip FROM sessions', [], ClickHouse::FIXEDSTRING_BINARY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;0.10.0's correctness pass is the fail-closed theme again: inserting PHP &lt;code&gt;null&lt;/code&gt; into a non-&lt;code&gt;Nullable&lt;/code&gt; &lt;code&gt;JSON&lt;/code&gt; column now throws instead of silently storing an empty &lt;code&gt;{}&lt;/code&gt;, and a clean server error (bad SQL, missing table) no longer reconnects and quietly discards your session's temp tables and &lt;code&gt;SET&lt;/code&gt; settings.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse&quot;&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;phpser-0-4-0-a-columnar-wire-format-for-rowsets&quot;&gt;phpser 0.4.0: a columnar wire format for rowsets&lt;/h3&gt;
&lt;p&gt;phpser's wire format v2 (0.3.0) added a columnar layout for homogeneous rowsets. Instead of writing each row's keys and values, &lt;code&gt;TAG_TABLE&lt;/code&gt; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$blob = phpser_serialize($rows);   // columnar automatically when rows share a schema
$rows = phpser_unserialize($blob);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 &lt;code&gt;TAG_OBJECT_SLOTS&lt;/code&gt; payload now routes through &lt;code&gt;__unserialize()&lt;/code&gt; when the class defines it, instead of installing raw property slots and running &lt;code&gt;__wakeup()&lt;/code&gt;, closing an invariant-rebuild bypass. And a crafted &lt;code&gt;__PHP_Incomplete_Class_Name&lt;/code&gt; property can no longer resurrect an attacker-chosen class name on a later decode.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser&quot;&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastjson-0-6-0-json-pointer-you-can-write-to-and-prebuilt-binaries&quot;&gt;fastjson 0.6.0: JSON Pointer you can write to, and prebuilt binaries&lt;/h3&gt;
&lt;p&gt;The June roundup covered &lt;code&gt;fastjson_pointer_get()&lt;/code&gt; and merge-patch. 0.5.0 completed the RFC 6901 surface with &lt;code&gt;fastjson_pointer_set()&lt;/code&gt;, which sets a value by pointer and returns the re-serialized document, and &lt;code&gt;fastjson_pointer_exists()&lt;/code&gt;, which distinguishes present-but-null from absent without materializing anything into PHP.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$updated = fastjson_pointer_set($json, '/user/email', '*Emails are not allowed*');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;0.5.0 also started publishing prebuilt &lt;code&gt;.so&lt;/code&gt; 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 &lt;code&gt;JsonSerializable&lt;/code&gt; mutates through an aliasing reference (the array is now copied first, as &lt;code&gt;ext/json&lt;/code&gt; does), and stack-exhausting nesting in &lt;code&gt;pointer_set&lt;/code&gt; rejected up front instead of crashing.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastjson&quot;&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;php-excel-2-4-0-bulk-reads-and-a-hardening-pass&quot;&gt;php_excel 2.4.0: bulk reads and a hardening pass&lt;/h3&gt;
&lt;p&gt;2.3.0 added bulk read paths. &lt;code&gt;readRange()&lt;/code&gt; pulls a rectangular block of cells into a 2D array in one call instead of per-cell PHP dispatch, &lt;code&gt;readSparseRow()&lt;/code&gt; and &lt;code&gt;readSparseCol()&lt;/code&gt; return only the occupied cells keyed by their original index, and &lt;code&gt;loadPartially()&lt;/code&gt; reads a single sheet's row slice without loading the rest of the workbook.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$block = $sheet-&amp;gt;readRange(0, 999, 0, 25);   // a 1000 x 26 region in one call
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2.4.0 is the atomic-save fix from the top of this post plus the rest of the fail-closed pass: &lt;code&gt;load()&lt;/code&gt; rejects a buffer above &lt;code&gt;UINT_MAX&lt;/code&gt; instead of truncating a payload over 4 GiB, &lt;code&gt;writeRow()&lt;/code&gt; and &lt;code&gt;writeCol()&lt;/code&gt; validate every value before writing any cell instead of committing half the row and then failing, and inverted ranges (first &amp;gt; last) are rejected instead of stored as garbage coordinates. One security tightening: &lt;code&gt;excel.license_key&lt;/code&gt; and &lt;code&gt;excel.license_name&lt;/code&gt; are now &lt;code&gt;PHP_INI_SYSTEM&lt;/code&gt;, so a per-request &lt;code&gt;ini_set()&lt;/code&gt; or &lt;code&gt;.user.ini&lt;/code&gt; can no longer overwrite the commercial license in a shared pool.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_excel&quot;&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fast-uuid-0-4-0-binary-and-batch-generators-and-ramsey-uuid-parity&quot;&gt;fast_uuid 0.4.0: binary and batch generators, and ramsey/uuid parity&lt;/h3&gt;
&lt;p&gt;0.3.0 added binary and batch generation. The binary generators (&lt;code&gt;uuid_v4_bin()&lt;/code&gt;, &lt;code&gt;uuid_v7_bin()&lt;/code&gt;, and the rest) return the raw 16 bytes with no canonical formatting, for a &lt;code&gt;BINARY(16)&lt;/code&gt; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$ids = uuid_v7_bin_batch(1000);   // 1000 raw 16-byte v7 UUIDs, monotonic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of 0.3.0 and 0.4.0, though, is &lt;code&gt;ramsey/uuid&lt;/code&gt; compatibility correctness. The compat &lt;code&gt;TimestampFirstCombCodec&lt;/code&gt; used a byte rotation instead of &lt;code&gt;ramsey/uuid&lt;/code&gt;'s first-6/last-6 swap, so a COMB written by &lt;code&gt;ramsey/uuid&lt;/code&gt; decoded to a different UUID; the layout now matches, and COMB columns interop. &lt;code&gt;Fields::getTimestamp()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;One breaking change to note if you catch it: &lt;code&gt;UnsupportedOperationException&lt;/code&gt; now extends &lt;code&gt;\LogicException&lt;/code&gt; instead of &lt;code&gt;\RuntimeException&lt;/code&gt;, matching &lt;code&gt;ramsey/uuid&lt;/code&gt; 4.x.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fast_uuid&quot;&gt;github.com/iliaal/fast_uuid&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-unglamorous-majority&quot;&gt;The unglamorous majority&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22216/failing-closed-what-shipped-across-seven-php-extensions</guid>
<pubDate>Fri, 10 Jul 2026 13:26:46 +0000</pubDate>
</item>
<item>
<title>Lowering the Floor: What Shipped Across Five PHP Extensions</title>
<link>https://coderlegion.com/20725/lowering-the-floor-what-shipped-across-five-php-extensions</link>
<description>&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Here is what else landed since the last set of releases.&lt;/p&gt;
&lt;h3 id=&quot;php-excel-2-2-0-a-write-mode-that-can-t-be-tricked-into-a-formula&quot;&gt;php_excel 2.2.0: a write mode that can't be tricked into a formula&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;ExcelFormat::AS_TEXT&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Write a user-controlled string that starts with &lt;code&gt;=&lt;/code&gt;, 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. &lt;code&gt;AS_TEXT&lt;/code&gt; writes the value verbatim instead: no leading-quote stripping, no implicit formula promotion, no numeric coercion.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// Untrusted input written exactly as given, never evaluated as a formula
$sheet-&amp;gt;write($row, 0, $userInput, null, ExcelFormat::AS_TEXT);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The implicit &quot;leading &lt;code&gt;=&lt;/code&gt; becomes a formula&quot; 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 &lt;code&gt;AS_NUMERIC_STRING&lt;/code&gt;, that type is honored instead of being silently overridden.&lt;/p&gt;
&lt;p&gt;The release before it, 2.1.0, added libxl 5.2.0 support, which surfaces the data validations stored in an xlsx file. &lt;code&gt;ExcelSheet::dataValidationSize()&lt;/code&gt; returns the count and &lt;code&gt;ExcelSheet::dataValidation($index)&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_excel&quot;&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;php-clickhouse-0-8-7-stream-a-file-straight-into-clickhouse&quot;&gt;php_clickhouse 0.8.7: stream a file straight into ClickHouse&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;insertFromStream()&lt;/code&gt; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$ch-&amp;gt;insertFromStream(
    'events',
    ['ts', 'user_id', 'event'],
    fopen('events.tsv', 'r'),
    'TSV',
    50000          // rows per batch
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 0.8.6 hardening pass is less visible but matters more if you run this in production. A &lt;code&gt;Map&lt;/code&gt; column read could leave the result row holding a freed array, a use-after-free that crashed when the row was later iterated or &lt;code&gt;json_encode&lt;/code&gt;d. Cloning a &lt;code&gt;ClickHouse&lt;/code&gt; object corrupted the heap; it now throws instead. And &lt;code&gt;setDatabase()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/php_clickhouse&quot;&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastchart-1-3-0-vector-pdf-output&quot;&gt;fastchart 1.3.0: vector PDF output&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;--with-pdfio&lt;/code&gt;, 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$chart-&amp;gt;renderToFile('report.pdf');   // or: $pdf = $chart-&amp;gt;renderPdf();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The other 1.2.0 addition is &lt;code&gt;getImageMapAreas()&lt;/code&gt;, which returns the chart's clickable regions as structured data (shape, coordinates, href, tooltip, index) instead of the pre-baked HTML that &lt;code&gt;getImageMap()&lt;/code&gt; emits. If you build your own overlays or generate links yourself, you no longer have to parse HTML to get the geometry back.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastchart&quot;&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;fastjson-0-4-0-json-pointer-merge-patch-and-a-relaxed-decode-mode&quot;&gt;fastjson 0.4.0: JSON Pointer, merge-patch, and a relaxed decode mode&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fastjson_pointer_get()&lt;/code&gt; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$email = fastjson_pointer_get($json, '/users/0/email');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fastjson_merge_patch()&lt;/code&gt; applies an RFC 7386 merge patch and returns the merged document, recursing into objects and treating a &lt;code&gt;null&lt;/code&gt; member as a delete. Both keep fastjson's &lt;code&gt;json_last_error&lt;/code&gt;-compatible error reporting, so a parse failure sets the error state or throws under &lt;code&gt;JSON_THROW_ON_ERROR&lt;/code&gt;, matching the native functions.&lt;/p&gt;
&lt;p&gt;The second addition is &lt;code&gt;FASTJSON_DECODE_RELAXED&lt;/code&gt;, which decodes the JSONC subset that &lt;code&gt;ext/json&lt;/code&gt; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$cfg = fastjson_decode($jsonc, true, 512, FASTJSON_DECODE_RELAXED);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;0.4.0 also added &lt;code&gt;fastjson_file_decode()&lt;/code&gt; and &lt;code&gt;fastjson_file_encode()&lt;/code&gt;, 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 &lt;code&gt;open_basedir&lt;/code&gt; apply exactly as they do for &lt;code&gt;file_get_contents()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/fastjson&quot;&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;phpser-0-2-0-a-faster-decoder-and-a-closed-key-smuggling-gap&quot;&gt;phpser 0.2.0: a faster decoder and a closed key-smuggling gap&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;ce-&amp;gt;properties_info&lt;/code&gt; 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.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Shape &lt;/th&gt;&lt;th&gt;Decode before &lt;/th&gt;&lt;th&gt;Decode after &lt;/th&gt;&lt;th&gt;Delta&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;DTO batch (same-class) &lt;/td&gt;&lt;td&gt;baseline &lt;/td&gt;&lt;td&gt;property-slot install &lt;/td&gt;&lt;td&gt;~22-25% faster&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Rowset &lt;/td&gt;&lt;td&gt;baseline &lt;/td&gt;&lt;td&gt;interned-string reuse &lt;/td&gt;&lt;td&gt;~9% faster&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Measured against phpser 0.1.x on arm64, median of 12 runs.&lt;/p&gt;
&lt;p&gt;The correctness fix: a crafted payload with a canonical numeric string array key, &lt;code&gt;&quot;5&quot;&lt;/code&gt;, now decodes to the integer key &lt;code&gt;5&lt;/code&gt;, matching native &lt;code&gt;unserialize()&lt;/code&gt; 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 &lt;code&gt;isset()&lt;/code&gt; and &lt;code&gt;array_key_exists()&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser&quot;&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20725/lowering-the-floor-what-shipped-across-five-php-extensions</guid>
<pubDate>Tue, 16 Jun 2026 17:43:14 +0000</pubDate>
</item>
<item>
<title>phpser: a fast, secure binary serializer for PHP cache workloads</title>
<link>https://coderlegion.com/19532/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads</link>
<description>&lt;p&gt;I've reached for igbinary on nearly every PHP project I've shipped in the last decade. It's smaller and faster than PHP's native &lt;code&gt;serialize()&lt;/code&gt;, it's stable, and it has been the obvious default for so long that reaching for it stopped being a decision.&lt;/p&gt;
&lt;p&gt;So phpser started as curiosity, not a complaint. igbinary is good. Could a serializer built specifically for cache workloads do better?&lt;/p&gt;
&lt;p&gt;I wanted two things from it. It should be fast on the shapes a cache actually holds, where a value is decoded far more often than it's encoded. And it should be safe to decode bytes from a store an attacker might reach, because &lt;code&gt;unserialize()&lt;/code&gt; on untrusted input is one of PHP's oldest exploit primitives. igbinary gives you the speed; the safety you bolt on yourself. phpser builds in both.&lt;/p&gt;
&lt;p&gt;On the shapes that matter for caches it encodes 10 to 70% faster than igbinary and decodes 12 to 75% faster, with packed numeric data also 65% smaller on the wire. Its signed mode refuses to decode any payload that wasn't produced with your key, so a poisoned cache entry never reaches the code that builds objects. The rest of this post is how it gets both.&lt;/p&gt;
&lt;h3 id=&quot;why-a-serializer-built-for-caches-&quot;&gt;Why a serializer built for caches?&lt;/h3&gt;
&lt;p&gt;Because igbinary optimizes for the general case, and a cache is not the general case, on two axes.&lt;/p&gt;
&lt;p&gt;The first is the read/write asymmetry. A PHP cache pays decode cost on every single read. Encode happens once, when you write the value; decode happens every time anything reads it back. For a read-heavy cache that ratio is easily 100 to 1. igbinary, like most general serializers, balances the two sides. A cache serializer shouldn't.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-asymmetry.svg&quot; alt=&quot;Encode runs once per write; decode runs on every read&quot;&gt;&lt;/p&gt;
&lt;p&gt;The second is trust. The thing reading those bytes back is often reading from redis, memcached, a file, or a cookie, any of which an attacker may be able to write to. A general serializer treats decode as a pure data operation. A cache serializer has to treat it as a trust boundary.&lt;/p&gt;
&lt;p&gt;igbinary is still the right default for general use. I went looking for the specific shapes where a cache-focused design could pull ahead, and there are three that show up everywhere in real PHP backends:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Packed numeric arrays. &lt;code&gt;range(0, 999)&lt;/code&gt;, ID lists, analytics buckets, sensor readings.&lt;/li&gt;
&lt;li&gt;Deep-nested structures. Trees, recursive config, nested document structures.&lt;/li&gt;
&lt;li&gt;Same-class object batches. Laravel queue payloads, cached Eloquent models, any array of a few hundred identical-shape DTOs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;designing-the-format-for-the-reader&quot;&gt;Designing the format for the reader&lt;/h3&gt;
&lt;p&gt;The performance half of phpser borrows an instinct from Rust's rkyv. rkyv's pitch is that deserialization should be nearly free, because the writer already laid the bytes out the way the reader needs them. You don't parse an rkyv archive so much as point at it.&lt;/p&gt;
&lt;p&gt;phpser isn't zero-copy, and I want to be precise about that before the comparison runs away. PHP values are refcounted &lt;code&gt;zval&lt;/code&gt;s with owned hashtables; you can't hand PHP a pointer into a cache buffer and call it an array. phpser does a real decode pass and builds real &lt;code&gt;zval&lt;/code&gt;s. It's not rkyv.&lt;/p&gt;
&lt;p&gt;What transferred is the instinct, not the mechanism. rkyv made me stop thinking about the wire format as a neutral container and start thinking about it as a set of instructions to the reader. If the writer knows something that saves the reader work, the writer should record it, even when that makes encoding a little more complex. Once you adopt that lens, a set of concrete decisions falls out of it.&lt;/p&gt;
&lt;h3 id=&quot;a-string-dictionary-and-an-intern-that-survives-decode&quot;&gt;A string dictionary, and an intern that survives decode&lt;/h3&gt;
&lt;p&gt;The honest starting point: a front-loaded string dictionary isn't novel. igbinary already does this, it calls them &lt;code&gt;compact_strings&lt;/code&gt;. Both serializers emit each distinct string once and reference it afterward, so the property name &lt;code&gt;&quot;created_at&quot;&lt;/code&gt; repeated across a thousand cached rows costs one copy, not a thousand.&lt;/p&gt;
&lt;p&gt;The dictionary isn't where the win is. The win is on the decode side, and it's the most direct application of the design-for-the-reader rule.&lt;/p&gt;
&lt;p&gt;When phpser decodes a dictionary string the first time, it allocates a &lt;code&gt;zend_string&lt;/code&gt;. Every later reference to that same dictionary index doesn't allocate; it bumps the refcount on the one already built. A thousand rows that all carry the key &lt;code&gt;&quot;user_id&quot;&lt;/code&gt; produce exactly one string allocation and 999 refcount increments. PHP's own machinery is built for exactly this, interned strings are shared by refcount throughout the engine, so phpser isn't fighting the runtime, it's leaning on it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-wire-format.svg&quot; alt=&quot;The dictionary is emitted once at the head; values reference it by varint index, and repeated strings reuse one interned zend_string by refcount&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;fast-to-encode-too&quot;&gt;Fast to encode, too&lt;/h3&gt;
&lt;p&gt;Designing for the reader could have meant a slow writer. It doesn't, because of two encoder choices, and the result is that phpser encodes faster than igbinary on every shape I test.&lt;/p&gt;
&lt;p&gt;The first is the intern cache. phpser keeps an open-addressed &lt;code&gt;zend_string*&lt;/code&gt;-to-slot hash, grown without eviction. Before hashing a string's bytes, it checks pointer identity: PHP interns string literals, so the &lt;code&gt;&quot;id&quot;&lt;/code&gt; in row 1 and the &lt;code&gt;&quot;id&quot;&lt;/code&gt; in row 900 are usually the same pointer and resolve with no byte work at all. Just as important, a unique value string, a name, an email, a SKU, takes a single-probe miss instead of a linear scan. The per-value dedup lookup stays off the critical path even on payloads full of strings that never repeat.&lt;/p&gt;
&lt;p&gt;The second is objects. Encoding a PHP object the obvious way calls &lt;code&gt;get_properties&lt;/code&gt;, which materializes a properties hashtable even for a plain object whose layout is fixed and known. For a batch of a few hundred DTOs that's hundreds of throwaway hashtables. phpser serializes a plain object straight from its declared property slots and skips the hashtable, the way native &lt;code&gt;serialize()&lt;/code&gt; does. PHP 8.4 lazy objects fall back to &lt;code&gt;get_properties&lt;/code&gt;, because their initializer has to run first.&lt;/p&gt;
&lt;h3 id=&quot;tagged-scalar-runs-and-building-the-array-in-place&quot;&gt;Tagged scalar runs, and building the array in place&lt;/h3&gt;
&lt;p&gt;Two more decisions, on the decode side, are what make the packed-numeric numbers as large as they are.&lt;/p&gt;
&lt;p&gt;The first is tagged scalar runs. igbinary encodes &lt;code&gt;[1, 2, 3, ...]&lt;/code&gt; as a sequence of tagged values: a type tag and a varint, a thousand times over. phpser detects a uniform run and emits one &lt;code&gt;PACKED_LONGS&lt;/code&gt; header plus the thousand integers as raw zigzag varints, no per-element tag. Decode becomes one tight loop with zero tag dispatch.&lt;/p&gt;
&lt;p&gt;The second is building the hashtable in place. When the wire format says &lt;code&gt;PACKED_LONGS&lt;/code&gt; of length N, the decoder knows the final size before it reads a single element. So it allocates the array once with &lt;code&gt;zend_new_array(N)&lt;/code&gt; and writes the values directly into PHP 8's packed &lt;code&gt;arPacked&lt;/code&gt; storage with &lt;code&gt;ZVAL_*&lt;/code&gt; macros. That skips N calls to &lt;code&gt;zend_hash_next_index_insert&lt;/code&gt;, and with them N hash computations, N capacity checks, and the incremental table growth that a naive decoder pays as it discovers the array's size one element at a time. The writer recorded the size so the reader could allocate once and fill, which is the rkyv instinct applied as far as a non-zero-copy format can take it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-build-in-place.svg&quot; alt=&quot;A naive decoder hashes and grows the table per element; phpser allocates once from the header count and writes slots directly&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-benchmarks&quot;&gt;The benchmarks&lt;/h3&gt;
&lt;p&gt;Here is the full shape-by-shape comparison, run on my machine, against igbinary. The bench harness (&lt;code&gt;bench.php&lt;/code&gt; in the repo) round-trips every shape for correctness first, then times encode and decode separately, because decode is the number that matters for a cache.&lt;/p&gt;
&lt;p&gt;Methodology: phpser 0.1.2, PHP 8.4.22-dev NTS, release build (not a debug or ASan build, which would inflate everything 2 to 5x), igbinary 3.2.17RC1, Intel Core i9-13950HX. 1,000 iterations per shape, median of 9 runs after a discarded warm-up.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Shape &lt;/th&gt;&lt;th&gt;Size: igbinary → phpser &lt;/th&gt;&lt;th&gt;Encode: igbinary → phpser &lt;/th&gt;&lt;th&gt;Decode: igbinary → phpser&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;packed_1k &lt;/td&gt;&lt;td&gt;5,495 → 1,941 B (&lt;strong&gt;-65%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;4.6 → 1.4 µs (&lt;strong&gt;-70%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;7.3 → 1.8 µs (&lt;strong&gt;-75%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;packed_10k &lt;/td&gt;&lt;td&gt;59,495 → 21,749 B (&lt;strong&gt;-63%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;46.4 → 13.7 µs (&lt;strong&gt;-70%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;74.0 → 18.9 µs (&lt;strong&gt;-74%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;deep_50 &lt;/td&gt;&lt;td&gt;419 → 424 B (+1%) &lt;/td&gt;&lt;td&gt;1.3 → 0.62 µs (&lt;strong&gt;-54%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;1.8 → 1.6 µs (&lt;strong&gt;-15%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;dto_100 &lt;/td&gt;&lt;td&gt;7,083 → 6,362 B (&lt;strong&gt;-10%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;15.5 → 13.9 µs (&lt;strong&gt;-10%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;26.9 → 23.5 µs (&lt;strong&gt;-13%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;dto_1000 &lt;/td&gt;&lt;td&gt;73,372 → 64,863 B (&lt;strong&gt;-12%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;194 → 165 µs (&lt;strong&gt;-15%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;275 → 227 µs (&lt;strong&gt;-18%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;rowset_100 &lt;/td&gt;&lt;td&gt;4,570 → 4,771 B (+4%) &lt;/td&gt;&lt;td&gt;10.0 → 7.3 µs (&lt;strong&gt;-27%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;10.7 → 10.8 µs (+1%)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;rowset_1000 &lt;/td&gt;&lt;td&gt;47,459 → 47,972 B (+1%) &lt;/td&gt;&lt;td&gt;157 → 71 µs (&lt;strong&gt;-55%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;104 → 107 µs (+4%)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;dto_mixed &lt;/td&gt;&lt;td&gt;21,644 → 17,927 B (&lt;strong&gt;-17%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;58.8 → 39.8 µs (&lt;strong&gt;-32%&lt;/strong&gt;) &lt;/td&gt;&lt;td&gt;112 → 81 µs (&lt;strong&gt;-28%&lt;/strong&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The packed rows are the ones that jump out: roughly two-thirds smaller and three-quarters faster to decode, on a real shape, not a synthetic micro-case. &lt;code&gt;packed_1k&lt;/code&gt; is &lt;code&gt;range(0, 999)&lt;/code&gt;, which is what an ID list or an analytics bucket looks like.&lt;/p&gt;
&lt;p&gt;The DTO rows are the relatable ones. &lt;code&gt;dto_1000&lt;/code&gt; is a thousand small typed objects of one class, the shape a Laravel queue batch or a page of cached models actually has. 12% smaller, 18% faster to decode, 15% faster to encode, from the dictionary dedup on property names and a class-entry lookup cache that amortizes &lt;code&gt;zend_lookup_class_ex&lt;/code&gt; across the batch. Encode is faster than igbinary on every row; the largest margins are on the object-heavy &lt;code&gt;dto_mixed&lt;/code&gt; (32% faster, 17% smaller) and the mixed &lt;code&gt;rowset_1000&lt;/code&gt; (55% faster).&lt;/p&gt;
&lt;h3 id=&quot;where-it-gives-a-little-back&quot;&gt;Where it gives a little back&lt;/h3&gt;
&lt;p&gt;The one row where phpser loses is the mixed associative rowset. &lt;code&gt;rowset_1000&lt;/code&gt; decodes about 4% slower than igbinary, and the rowset payloads run 1 to 4% larger. That's the front-loaded dictionary showing its one downside: the decoder walks the dictionary header before it touches values, and on a heterogeneous rowset with few repeated strings that header walk doesn't buy back its cost. It's a small tax, and it's on the exact axis I chose to de-prioritize, but it's real and measured, so there it is.&lt;/p&gt;
&lt;p&gt;The structural limit is the same decision seen from another angle: phpser isn't streamable. The dictionary lives at the head of the payload and values reference it by index, so you can't decode the stream incrementally as it arrives. The front-loaded dictionary is what makes the other decodes fast and what makes streaming impossible. You don't get to keep both. If you need a streaming parser, this is the wrong format.&lt;/p&gt;
&lt;p&gt;I also cross-checked the whole suite on arm64 to make sure none of this was an x86 quirk. Same direction on every shape, with narrower encode margins on the object cases. The decode wins and the single &lt;code&gt;rowset_1000&lt;/code&gt; decode tax both reproduce.&lt;/p&gt;
&lt;h3 id=&quot;signed-payloads-safe-to-decode-from-an-untrusted-cache&quot;&gt;Signed payloads: safe to decode from an untrusted cache&lt;/h3&gt;
&lt;p&gt;The performance half is only one reason a cache serializer is its own problem. The other is that decoding attacker-controlled bytes is dangerous. Native &lt;code&gt;unserialize()&lt;/code&gt; on untrusted input lets a crafted payload instantiate any allowed class and drive its &lt;code&gt;__wakeup&lt;/code&gt;, &lt;code&gt;__destruct&lt;/code&gt;, or other magic methods into a state the code never anticipated. That's the mechanism behind object-injection and gadget-chain attacks, and a cache is the soft spot: a redis instance, a memcached pool, a file cache, or a cookie is exactly the kind of store an attacker reaches in a real incident, and whatever sits there gets decoded on the next read.&lt;/p&gt;
&lt;p&gt;phpser's answer is a signed mode built on HMAC-SHA256. You serialize with a secret key, and you refuse to decode anything that wasn't signed with that same key. Verification is constant-time and runs before any decoding work, so a tampered or foreign-keyed payload never reaches the part of the decoder that builds values or constructs objects.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://ilia.ws/user/pages/01.blog/20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads/phpser-signed-trust-boundary.svg&quot; alt=&quot;Native unserialize decodes attacker bytes before checking anything; the signed path verifies the HMAC first and returns null on mismatch, so nothing is decoded&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;$key = random_bytes(32);            // generate once, keep it in app config or a secrets manager

// on write
$blob = phpser_serialize_signed($cacheValue, $key);
$redis-&amp;gt;set('user:42', $blob);

// on read
$blob  = $redis-&amp;gt;get('user:42');
$value = phpser_unserialize_signed($blob, $key);

if ($value === null) {
    // tampered, truncated, or signed with a different key.
    // nothing was decoded; treat it as a cache miss and rebuild.
    $value = rebuild_user(42);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The contract is deliberately blunt. &lt;code&gt;phpser_unserialize_signed()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; on any signature failure rather than throwing, so a poisoned cache entry degrades to a miss instead of an exception in a hot read path. The decode only proceeds once the MAC matches. This is authentication, not encryption: the bytes are still readable, but they can't be forged without the key, and that's the property that keeps a crafted object graph out of your decoder.&lt;/p&gt;
&lt;p&gt;Signed mode also refuses an empty key. An empty key would reduce HMAC-SHA256 to a fixed, keyless tag anyone can recompute, so a caller writing &lt;code&gt;phpser_serialize_signed($v, getenv('SECRET') ?: '')&lt;/code&gt; with the variable unset would be shipping forgeable payloads without knowing it. Both signed entry points throw on an empty key before doing any work, so that misconfiguration fails loudly instead of silently defeating the signature.&lt;/p&gt;
&lt;p&gt;If you genuinely can't sign, because you're decoding bytes from a source you don't control and can't key, the second line of defense is &lt;code&gt;allowed_classes&lt;/code&gt;, with the same shape as PHP's native &lt;code&gt;unserialize()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-php&quot;&gt;// reject every class: unknown objects decode as __PHP_Incomplete_Class, never instantiated
$value = phpser_unserialize($blob, ['allowed_classes' =&amp;gt; false]);

// or allowlist only the classes you actually expect to read back
$value = phpser_unserialize($blob, ['allowed_classes' =&amp;gt; [UserDto::class, OrderDto::class]]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same option works on &lt;code&gt;phpser_unserialize_signed()&lt;/code&gt; too, so you can combine a valid signature with a class allowlist for defense in depth. Underneath both paths, the decoder is hardened on its own: a recursion depth cap of 512 bounds stack use against deliberately deep payloads (decode returns &lt;code&gt;null&lt;/code&gt;, encode throws), and crafted payloads naming a missing enum case or a non-serializable class like &lt;code&gt;Closure&lt;/code&gt; are rejected rather than crashing, matching what PHP's own &lt;code&gt;unserialize()&lt;/code&gt; refuses.&lt;/p&gt;
&lt;h3 id=&quot;what-i-took-away&quot;&gt;What I took away&lt;/h3&gt;
&lt;p&gt;igbinary is still the serializer I'd reach for on a general-purpose workload, and I'll keep using it. It's mature, it's everywhere, and on a mixed rowset it's still a hair ahead on decode.&lt;/p&gt;
&lt;p&gt;For a read-heavy cache, phpser gives me two things at once. It's faster on the shapes caches actually hold, encode and decode both, because the wire format is designed around the reader rather than balanced between reader and writer. And signed mode means I can decode from redis without treating every read as a potential injection. Speed and trust are the two things a cache serializer has to get right, and they're the two things a general serializer leaves half-finished. Building both in was the point.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pie install iliaal/phpser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source, wire-format spec, and the bench harness: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/iliaal/phpser&quot;&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19532/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads</guid>
<pubDate>Tue, 02 Jun 2026 19:01:31 +0000</pubDate>
</item>
</channel>
</rss>