<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ilia Alshanetsky</title>
    <description>The latest articles on DEV Community by Ilia Alshanetsky (@iliaa).</description>
    <link>https://dev.to/iliaa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3866511%2Ff435f62a-66cb-4c1a-bdf4-aa79870ddd9a.png</url>
      <title>DEV Community: Ilia Alshanetsky</title>
      <link>https://dev.to/iliaa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iliaa"/>
    <language>en</language>
    <item>
      <title>php2shell: two ways PHP corrupts its own memory, and why turning that into a shell is still hard</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:39:33 +0000</pubDate>
      <link>https://dev.to/iliaa/php2shell-two-ways-php-corrupts-its-own-memory-and-why-turning-that-into-a-shell-is-still-hard-5afi</link>
      <guid>https://dev.to/iliaa/php2shell-two-ways-php-corrupts-its-own-memory-and-why-turning-that-into-a-shell-is-still-hard-5afi</guid>
      <description>&lt;p&gt;A single unauthenticated request can run code as your web worker on PHP before 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35. The bug is real, the patch is out, and reliable exploitation outside a lab is far harder than the headlines suggest. This is the full analysis, and an honest account of how long it took to prove (and why so long).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Lab-only research on a patched vulnerability. Knowledge sharing, not a how-to.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read nothing else, do these five things
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade now&lt;/strong&gt; to PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35. These fix &lt;strong&gt;CVE-2026-109618&lt;/strong&gt; and &lt;strong&gt;CVE-2026-109619&lt;/strong&gt;. On an end-of-life branch, do not hand-apply an isolated source patch on top of the last EOL release: it closes these two CVEs but leaves you exposed to everything else fixed since the branch went out of support. Get a maintained build from your distro or a package maintainer (Ondřej Surý's &lt;a href="https://deb.sury.org/" rel="noopener noreferrer"&gt;&lt;code&gt;deb.sury.org&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://rpms.remirepo.net/" rel="noopener noreferrer"&gt;Remi Collet's repo&lt;/a&gt;, Red Hat's module streams), which carry patched builds reaching back through 5.6, or upgrade. Check the package changelog for the CVE IDs before you trust any build. On &lt;strong&gt;Windows&lt;/strong&gt;, take the &lt;code&gt;-1&lt;/code&gt; rebuild (8.5.12-1 and friends): that one bundles a separate &lt;a href="https://github.com/google/brotli/releases/tag/v1.2.1" rel="noopener noreferrer"&gt;Brotli 1.2.1&lt;/a&gt; fix for &lt;strong&gt;CVE-2026-111497&lt;/strong&gt;, an out-of-bounds write reachable through the cURL extension when automatic response decompression is on. Different bug, same download.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt;&lt;/strong&gt;, and turn off every extension and feature you don't use. The session flag is PHP's own &lt;a href="https://www.php.net/archive/2026.php#2026-10-23-1" rel="noopener noreferrer"&gt;published mitigation&lt;/a&gt;. The general habit, shrinking the attack surface, is the part that pays off on the next bug you haven't heard about yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you run mod_php, move to php-fpm.&lt;/strong&gt; The FPM process model removes an entire class of these chains and isolates workers from the web server. It raises the bar. It does not close the door: I got a shell on FPM too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploiting this in the wild is hard.&lt;/strong&gt; Not impossible, hard. Every chain is calibrated to one exact build, the heap grooming is fragile, and even with current LLMs doing the heavy lifting it took days of dead-ends to reach a reliable shell. Sansec and Wiz have both reported public "php2shell" PoCs that do not work against a default install.&lt;/li&gt;
&lt;li&gt;This article explains the mechanism and the mitigations. It is not a copy-paste exploit kit, and the working lab harness targets the vulnerable 8.5.11 image only.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Credit where it is due
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovery and analysis:&lt;/strong&gt; &lt;a href="https://calif.io/php2shell/" rel="noopener noreferrer"&gt;Calif&lt;/a&gt; and &lt;a href="https://www.wiz.io/blog/php2shell-exploitation-so-far" rel="noopener noreferrer"&gt;Wiz&lt;/a&gt;. Their public write-ups, along with the PHP source patch, are what let me understand the issue well enough to reproduce it. If you want the discovery story and the wider impact catalogue, read theirs first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; the PHP team. "Avoid state corruption via bailout", authored by Arnaud Le Blanc, Ilija Tovilo, Jakub Zelenka, Nora Dossche, and Nicolas Grekas. They turned a messy memory-corruption class into a two-line conceptual fix: stop using &lt;code&gt;zend_bailout&lt;/code&gt; on OOM, and reset the globals that startup touches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review:&lt;/strong&gt; special thanks to Arnaud Le Blanc for reviewing an early draft and giving sharp, generous feedback that made this write-up more accurate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this wasn't Log4Shell
&lt;/h2&gt;

&lt;p&gt;The response was loud. &lt;a href="https://x.com/NSACyber/status/2112903220209098897" rel="noopener noreferrer"&gt;NSA&lt;/a&gt;, the &lt;a href="https://x.com/FBI/status/2113692197568630999" rel="noopener noreferrer"&gt;FBI&lt;/a&gt;, and the UK's &lt;a href="https://x.com/NCSC/status/2112848265074847828" rel="noopener noreferrer"&gt;NCSC&lt;/a&gt; all told anyone who could not patch immediately to take the affected system offline until it was mitigated; &lt;a href="https://x.com/CISAgov/status/2113984287481716741" rel="noopener noreferrer"&gt;CISA said the same in its October 24 update&lt;/a&gt;, and days earlier had reported active exploitation and added both CVEs to its Known Exploited Vulnerabilities list. I would take the "active exploitation" label with a grain of salt. In waves like this, what gets reported as exploitation is often mass scanning and failed attempts. More than one vendor has quietly walked back a confident "actively exploited" claim once someone checked whether the traffic was a scan or a real shell, and this is not a bug that falls to a generic payload. The PHP team never called for shutting servers down. Their guidance was to patch, then apply the config mitigations.&lt;/p&gt;

&lt;p&gt;Everyone reached for the Log4Shell comparison. So why wasn't the whole internet on fire? Why targeted hits instead of a mass, worming wave? The answer is in the offsets, and it is the reason this write-up exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: PHP panics by longjmp, and panic skips cleanup
&lt;/h2&gt;

&lt;p&gt;When a PHP request exhausts &lt;code&gt;memory_limit&lt;/code&gt;, the engine historically raised a fatal error and recovered with &lt;code&gt;zend_bailout()&lt;/code&gt;, a &lt;code&gt;longjmp&lt;/code&gt; back to a safe point. That unwind is the problem. A &lt;code&gt;longjmp&lt;/code&gt; does not run the cleanup that a normal return would. If the allocation failed halfway through building a data structure, the half-built structure survives, and a global still points at it.&lt;/p&gt;

&lt;p&gt;Both CVEs are that same mistake, caught in two different places.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CVE&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;CVE-2026-109618&lt;/strong&gt; · Bailout state corruption&lt;/td&gt;
&lt;td&gt;An OOM during request startup, request parsing, or session handling leaves engine globals pointing at freed or half-initialized memory. The next request on the same worker inherits the mess.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;CVE-2026-109619&lt;/strong&gt; · Pre-RINIT decode reuse&lt;/td&gt;
&lt;td&gt;The upload-progress feature decodes a session &lt;em&gt;before&lt;/em&gt; the request re-initializes its globals. With the unserializer left in a dirty state by a prior OOM, that early decode reuses a dangling structure.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The patch stops the OOM from &lt;code&gt;longjmp&lt;/code&gt;-ing mid-operation. It defers the out-of-memory error and raises it at controlled chokepoints, an uncatchable &lt;code&gt;UnwindExit&lt;/code&gt; while a userland frame is on the stack, and resets the globals that startup reads. 389 files changed, and a pile of new &lt;code&gt;oom_rfc1867_*.phpt&lt;/code&gt; tests pin the exact spots where an allocation could fail mid-structure. On the supported branches an ordinary &lt;code&gt;memory_limit&lt;/code&gt; hit is now a graceful fatal that still runs shutdown handlers; only a request that blows past a new hard ceiling, 200% of &lt;code&gt;memory_limit&lt;/code&gt;, forces the process out with &lt;code&gt;_exit(137)&lt;/code&gt;. That rework did not reach the out-of-support branches: the fixes carried by distros and maintainers for 7.4, 8.0, and 8.1 take the blunt route and simply &lt;code&gt;_exit(1)&lt;/code&gt; on the offending allocation, tearing the whole process down rather than attempting the unwind that caused the bug. Either way, the corrupting bailout is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The primitive: one signed integer turns a session into an arbitrary write
&lt;/h2&gt;

&lt;p&gt;Everything downstream rests on one function in the unserializer. &lt;code&gt;var_tmp_var()&lt;/code&gt; hands out scratch slots from a block of destructor entries. It reads how many slots are used, then returns a pointer past them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ext/standard/var_unserializer.re  (simplified)&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;zval&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;var_tmp_var_num&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;php_unserialize_data_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;var_hashx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zend_long&lt;/span&gt; &lt;span class="n"&gt;num&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;var_dtor_entries&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;var_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;var_hashx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;last_dtor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;zend_long&lt;/span&gt; &lt;span class="n"&gt;used_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// &amp;lt;-- SIGNED&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;used_slots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;var_hash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;used_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ZVAL_UNDEF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;var_hash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;used_slots&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;var_hash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;used_slots&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;   &lt;span class="c1"&gt;// base + used_slots*16&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;used_slots&lt;/code&gt; is a signed &lt;code&gt;zend_long&lt;/code&gt;. Make it negative and the returned pointer lands &lt;em&gt;before&lt;/em&gt; the block, anywhere the attacker can reach. The session decoder then writes the parsed value through that pointer, one 8-byte value plus its type tag per session variable. Control where the block sits and what &lt;code&gt;used_slots&lt;/code&gt; holds, and you have a controlled write with controlled data.&lt;/p&gt;

&lt;p&gt;With that write, you build a lie: a chunk of memory that PHP believes is an array. The only field that matters is the array's destructor pointer. Aim it at the internal handler for &lt;code&gt;system()&lt;/code&gt;, and the moment PHP tears the "array" down, it runs your command.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20261029-php2shell-two-ways-php-corrupts-its-own-memory%2Fphp2shell-fake-hashtable.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20261029-php2shell-two-ways-php-corrupts-its-own-memory%2Fphp2shell-fake-hashtable.svg" alt="A session-decode write with a negative used_slots lands attacker bytes on a forged HashTable whose pDestructor points at zif_system, so tearing the array down runs system(id) as www-data" width="900" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;PHP's array teardown calls &lt;code&gt;pDestructor(&amp;amp;bucket)&lt;/code&gt;. Overwrite that one pointer with the address of &lt;code&gt;zif_system&lt;/code&gt;, put the command where the bucket points, and freeing the array is a shell.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  From one write to a shell: the four-request mod_php chain
&lt;/h2&gt;

&lt;p&gt;A single write primitive is not a shell. You still need to know &lt;em&gt;where&lt;/em&gt; to write, which means defeating ASLR, and you need something that turns a write into execution. The mod_php chain does both across four requests on one persistent Apache worker. It is pure HTTP. No files on the target, no debugger, no application code. The chain scripts live in &lt;a href="https://github.com/iliaal/php2shell/tree/master/off_chain" rel="noopener noreferrer"&gt;off_chain/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20261029-php2shell-two-ways-php-corrupts-its-own-memory%2Fphp2shell-four-request-chain.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20261029-php2shell-two-ways-php-corrupts-its-own-memory%2Fphp2shell-four-request-chain.svg" alt="The four-request mod_php chain on one worker: A1 primes an OOM that leaves send_cookie and a dangling session id, B1 leaks libphp and heap bases off the Set-Cookie header, A2 primes again, and B2 sprays a fake reference and fake HashTable whose pDestructor is zif_system" width="910" height="162"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The payoff in B2 is a classic one: a fake PHP array whose destructor pointer is the internal handler for &lt;code&gt;system()&lt;/code&gt;. When PHP tears the array down, it calls that "destructor" with attacker-controlled data, and the command runs as &lt;code&gt;www-data&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat out/proof
uid=33(www-data) gid=33(www-data) groups=33(www-data)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the leak, every address is a guess, and a guess on a 64-bit ASLR heap is not an exploit. The forged &lt;code&gt;zend_string&lt;/code&gt; read in B1 is what makes the rest deterministic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the layout from the wire: the Set-Cookie oracle
&lt;/h3&gt;

&lt;p&gt;The leak needs no local access. It rides out on a header the server sends unprompted: when PHP starts a session it writes the session id verbatim into &lt;code&gt;Set-Cookie: PHPSESSID=...&lt;/code&gt;. That id is a &lt;code&gt;zend_string&lt;/code&gt;, and B1 controls which one.&lt;/p&gt;

&lt;p&gt;A1's bailout freed the session-id buffer but left &lt;code&gt;send_cookie=1&lt;/code&gt; and the dangling &lt;code&gt;PS(id)&lt;/code&gt; pointer in place. B1 reclaims that freed chunk with a crafted multipart field and overwrites it with a fake &lt;code&gt;zend_string&lt;/code&gt; header: a benign refcount and type word, hash zero, and a &lt;code&gt;len&lt;/code&gt; field widened to 2048. The character data is left pointing at the surrounding chunk. When the END cleanup emits the cookie, PHP copies &lt;code&gt;len&lt;/code&gt; bytes starting from that pointer, so the &lt;code&gt;PHPSESSID&lt;/code&gt; value becomes a 2 KB window of live engine memory rather than a 32-character id.&lt;/p&gt;

&lt;p&gt;Everything after that is parsing on the attacker's side. &lt;a href="https://github.com/iliaal/php2shell/blob/master/off_chain/parse_leak.php" rel="noopener noreferrer"&gt;parse_leak.php&lt;/a&gt; URL-decodes the cookie, walks it in 8-byte steps, and keeps the qwords that fall in the heap range. The pointer that recurs with its low 12 bits equal to &lt;code&gt;0x7a0&lt;/code&gt; is &lt;code&gt;zval_ptr_dtor&lt;/code&gt;, so &lt;code&gt;libphp&lt;/code&gt; base is that value minus &lt;code&gt;0x7b47a0&lt;/code&gt;; bucketing the rest by 2 MB chunk recovers the main heap base. Two reads off one response header, and ASLR is gone from outside the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six paths, two execution models, one scorecard
&lt;/h2&gt;

&lt;p&gt;The same primitive reaches code execution through several routes. They are not equal. Some are pure HTTP, one still needs a debugger, and the FPM and mod_php models behave very differently under the same bug. Every path's exact container setup and commands are in &lt;a href="https://github.com/iliaal/php2shell/blob/master/RCE_PATHS.md" rel="noopener noreferrer"&gt;RCE_PATHS.md&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mod_php&lt;/td&gt;
&lt;td&gt;Four-request chain, &lt;code&gt;display_errors=On&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;span&gt;shell&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mod_php&lt;/td&gt;
&lt;td&gt;Same chain, stock &lt;code&gt;php.ini-production&lt;/code&gt;. Pure HTTP.&lt;/td&gt;
&lt;td&gt;&lt;span&gt;shell&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mod_php&lt;/td&gt;
&lt;td&gt;Path B with a &lt;code&gt;popen&lt;/code&gt; ROP stack instead of a direct call&lt;/td&gt;
&lt;td&gt;&lt;span&gt;shell&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;php-fpm&lt;/td&gt;
&lt;td&gt;Session-decode reuse, app-visible leak, ASLR on&lt;/td&gt;
&lt;td&gt;&lt;span&gt;shell&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;php-fpm&lt;/td&gt;
&lt;td&gt;Path C fired through a &lt;code&gt;popen&lt;/code&gt; ROP pivot, no forged &lt;code&gt;execute_data&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;span&gt;shell&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;E&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;mod_php&lt;/td&gt;
&lt;td&gt;Multipart partial-bucket teardown (path1)&lt;/td&gt;
&lt;td&gt;&lt;span&gt;crash is HTTP-only; no native leak and the groom is blocked, so the write needs an external leak or a debugger&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why FPM is the harder target
&lt;/h3&gt;

&lt;p&gt;mod_php runs PHP inside a long-lived Apache worker that shares the server's address space and survives across requests. That persistence is exactly what the four-request chain feeds on. php-fpm exits the worker the moment &lt;code&gt;php_request_startup()&lt;/code&gt; fails, so the whole startup-failure class, including path1, simply cannot carry to a second request there. FPM is the more resilient model, and moving to it is good advice.&lt;/p&gt;

&lt;p&gt;It is not a fix. The session-decode reuse (Path C) lives in exactly the pre-RINIT window FPM still has, and I landed a clean &lt;code&gt;uid=33&lt;/code&gt; on a stock FPM container. FPM falls two independent ways, in fact: the forged-frame route above, and a data-only variant (Path C2) that carries no fake &lt;code&gt;execute_data&lt;/code&gt; at all. It overwrites the destructor with a &lt;code&gt;jump_fcontext&lt;/code&gt; stack pivot into a short &lt;code&gt;popen&lt;/code&gt; ROP chain, and fires it through the upload-progress hash update's own old-value destructor rather than the teardown. Same leak, same reuse, no forged frame. "Harder" is the honest word. "Safe" is not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20261029-php2shell-two-ways-php-corrupts-its-own-memory%2Fphp2shell-fpm-pre-rinit.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20261029-php2shell-two-ways-php-corrupts-its-own-memory%2Fphp2shell-fpm-pre-rinit.svg" alt="FPM kills the worker on a failed request startup, which blocks the mod_php chains, but Path C slips through: request A's OOM leaves the unserialize context d dangling at level 1, and request B's upload-progress callback decodes a session before RINIT resets the worker, reusing and overwriting d" width="910" height="160"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;FPM kills the worker on a failed request startup, which is what blocks the mod_php chains. Path C slips through anyway: the upload-progress decode runs *before&lt;/em&gt; RINIT resets the worker, so request A's dangling &lt;code&gt;d&lt;/code&gt; is still there for request B to reuse and overwrite with the same fake-HashTable frame. That pre-RINIT window is CVE-2026-109619.*&lt;/p&gt;

&lt;p&gt;One more piece of honesty about the FPM result. The mod_php chains run out of memory at the stock 128 MB limit, driven by the request itself. Nothing on the target has to cooperate. The FPM chains here lean on a lab endpoint that hands back the pointer leak and, for determinism, sets &lt;code&gt;memory_limit&lt;/code&gt; to 4 MB with &lt;code&gt;ini_set()&lt;/code&gt; so a 1 MB session decode is enough to OOM. The 4 MB is a lab convenience, not a requirement. The arming OOM reaches the real 128 MB limit: a session record that declares a huge array count makes the deserializer pre-allocate the whole bucket table, 128 MB from under 5 MB of input, before it parses one element. The constraint the easy framing misses is where the OOM lands. It has to fire inside the session decode, so &lt;code&gt;php_var_unserialize_destroy()&lt;/code&gt; is skipped and the context dangles. An ordinary base64 or image-resize OOM in application code aborts in the wrong place and arms nothing. So the FPM chain needs three things from the target, not one: the pointer leak, an OOM inside session deserialization, and attacker-influenced session content to carry that record. Stock PHP hands over none of them, and the leak is still the hardest.&lt;/p&gt;

&lt;p&gt;A reader flagged that my FPM lab spoke FastCGI straight to php-fpm, a shortcut, not a requirement. So I reworked the lab to do it properly; the repo ships that path now: the same leak and write travel the full route over plain HTTP through a stock nginx with php-fpm unreachable from outside, and still land &lt;code&gt;uid=33&lt;/code&gt;, roughly 6 fresh workers in 10, every miss a clean retry. The FPM result is remote through a normal reverse proxy, not an artifact of reaching php-fpm directly.&lt;/p&gt;

&lt;p&gt;What I did leave on the table, deliberately, were routes that lean on an application &lt;em&gt;weakness&lt;/em&gt;. If the target calls &lt;code&gt;unserialize()&lt;/code&gt; on attacker input the primitive is one request away, but the &lt;a href="https://www.php.net/manual/en/function.unserialize.php" rel="noopener noreferrer"&gt;PHP manual&lt;/a&gt; has warned for years that unserializing untrusted input gets you hacked. That is object injection, the application's bug, not PHP's. I wanted the vulnerability shown in core PHP on a stock install, through mechanisms every install ships, not against someone's vulnerable code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The barrier the PoCs skip: every stage needs the same worker
&lt;/h2&gt;

&lt;p&gt;The chains above share a requirement the lab quietly satisfies. All four requests have to hit the &lt;em&gt;same&lt;/em&gt; PHP worker, because the corrupted state the exploit builds lives in that one worker's memory. With a single worker that is free, and a single worker is where I started. On a production server, with a pool of workers behind a load balancer, landing four requests in a row on one specific worker looks like the hardest part of the attack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the lab:&lt;/strong&gt; one worker (&lt;code&gt;apache2 -X&lt;/code&gt;, or a single FPM child). Every request lands on it, so the state carries for free. This is where most public PoCs quietly live, and it is not a real deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In production:&lt;/strong&gt; many workers, a load balancer in front, other clients in the mix. The four stages scatter across the pool, and the state built on one worker is invisible to the next.&lt;/p&gt;

&lt;p&gt;The obvious fix, HTTP keep-alive to pin one connection to one worker, does not work here. The chain OOMs twice on purpose, and PHP closes the connection on a bailout: the response is &lt;code&gt;HTTP/1.0 500 ... Connection: close&lt;/code&gt; even when the request asked to keep the socket alive, and the next request on it gets EOF. I confirmed it is the SAPI bailout handling and not an unread body: a plain fatal error and a script-level OOM both close the socket the same way. Keep-alive carries a chain of healthy 200s. It cannot carry a chain whose whole point is to run out of memory.&lt;/p&gt;

&lt;p&gt;What does work, in the lab, is process pinning: hold every worker but one busy with slow, half-sent requests, so the single free worker serves all four stages. I got this to a clean shell on mod_php, 9 of 10 prefork children pinned, and on php-fpm, 3 of 4 workers pinned with truncated FastCGI records. Both landed &lt;code&gt;uid=33&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pinning in a lab is one thing. Against a live server it is the noisy option: other clients hit that free worker too, you rarely know how many workers there are, and a fleet of half-sent connections is the shape a WAF or a bored load balancer drops. But pinning turned out to be unnecessary. Behind a stock-default nginx the php-fpm workers share one listen socket and the kernel hands new connections out in a strict round robin, so on a quiet pool the attacker never lands four stages on one worker: they walk the whole pool. Prime every worker, leak every worker, prime again, fire every worker. That is 2N ordinary complete requests for a pool of N, no slow bodies, nothing held open. I ran this end to end through a stock nginx with php-fpm unreachable from outside, on pools of 2, 4, and 8, and it landed &lt;code&gt;uid=33&lt;/code&gt; on every pool. Live traffic perturbs the round robin and I have not measured the hit rate under load, so this is not a one-shot against a busy server. But it refutes the idea that a worker pool behind default buffering is itself a defense. The barrier that actually holds is the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every number is build-specific
&lt;/h2&gt;

&lt;p&gt;This is why the public exploits don't work on your server. The chain is a list of offsets, and every one of them is pinned to a single compiled binary. Change the PHP build, the distro, or the CPU architecture, and they all move. A PoC calibrated on the author's machine is a crash on yours.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Offset&lt;/th&gt;
&lt;th&gt;x86-64&lt;/th&gt;
&lt;th&gt;aarch64 (52-bit VA)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mod_php &lt;code&gt;zval_ptr_dtor&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;libphp+0x7b47a0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;libphp+0x761b80&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mod_php &lt;code&gt;zif_system&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;libphp+0x5996e0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;libphp+0x5314f0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;user-pointer range&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x700000000000&lt;/code&gt; to &lt;code&gt;0x800000000000&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0xf00000000000&lt;/code&gt; to &lt;code&gt;0x1000000000000&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BG(unserialize).data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bg+0x368&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bg+0x348&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fpm leak: &lt;code&gt;BASE&lt;/code&gt; (from leaked pie_ptr)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pie_ptr - 0x7c9777&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pie_ptr - 0x6e7810&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I ported the two leak-based paths from x86-64 to a stock ARM server and both worked: the mod_php chain gave a shell in &lt;strong&gt;4 runs out of 5&lt;/strong&gt; across randomized ASLR bases, and the FPM chain in &lt;strong&gt;3 out of 3&lt;/strong&gt;. That port did not need new exploitation ideas. PHP's internal struct layout and allocator bin sizes are identical on 64-bit ARM, so the small-bin spray and the heap grooming carried over untouched. What changed was a short list of symbol offsets and one pointer-range constant. The leak absorbs the rest, because it reads the current build's addresses at run time instead of hardcoding them. The full re-derivation method is in &lt;a href="https://github.com/iliaal/php2shell/blob/master/PORTING.md" rel="noopener noreferrer"&gt;PORTING.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The fixed-address FPM variant, and why I left it out&lt;/p&gt;

&lt;p&gt;There is a sixth route I built early and then abandoned: an FPM type-confusion that hardcodes every address instead of leaking one. The worker runs under &lt;code&gt;setarch -R&lt;/code&gt; with ASLR off, so the PIE base and the reclaimed unserialize buffer &lt;code&gt;d&lt;/code&gt; sit at fixed addresses across runs. Two files carry the whole exploit: a session blob &lt;code&gt;sess_T&lt;/code&gt; whose values are &lt;code&gt;d&lt;/code&gt;-relative pointers into a fake &lt;code&gt;var_dtor_entries&lt;/code&gt; table, and a fill payload &lt;code&gt;fillU.bin&lt;/code&gt; that sprays that table into the freed block so the signed-&lt;code&gt;used_slots&lt;/code&gt; write lands on a crafted &lt;code&gt;HashTable&lt;/code&gt;. No leak, no second request to parse one. On the exact build it was tuned against, it drops a shell on the first try.&lt;/p&gt;

&lt;p&gt;It is also the only path I could not reproduce on a clean official image. Nothing about the bug changed; the heap did. On the freshly pulled &lt;code&gt;php:8.5.11-fpm&lt;/code&gt; the reclaimed &lt;code&gt;d&lt;/code&gt; came back &lt;code&gt;0x495000&lt;/code&gt; away from the address baked into &lt;code&gt;sess_T&lt;/code&gt;, so every &lt;code&gt;d&lt;/code&gt;-relative pointer in the payload aimed into unmapped memory. Re-deriving &lt;code&gt;d&lt;/code&gt; was not enough. The fill groom itself mis-landed: the spray reclaimed the &lt;code&gt;d&lt;/code&gt; allocation as its own &lt;code&gt;zend_string&lt;/code&gt;, which put the string's length word (&lt;code&gt;0x1fea&lt;/code&gt;, 8170) exactly where &lt;code&gt;last_dtor&lt;/code&gt; lives at &lt;code&gt;d+0x10&lt;/code&gt;, and &lt;code&gt;var_tmp_var&lt;/code&gt; then dereferenced 8170 as an entry count and faulted. The fixed address was not stale, it was occupied by the wrong object.&lt;/p&gt;

&lt;p&gt;Fixing that is pure allocator choreography with no new capability at the end of it: re-derive &lt;code&gt;d&lt;/code&gt; under the probe, rebase every pointer in &lt;code&gt;sess_T&lt;/code&gt; and &lt;code&gt;fillU.bin&lt;/code&gt; onto the new base, then re-tune the fill part size and count until the reclaim lands on the freed &lt;code&gt;var_dtor_entries&lt;/code&gt; block instead of on &lt;code&gt;d&lt;/code&gt;, and repeat for the next image release. Path C reaches the same FPM worker with a live leak that reads the current base at run time, so it absorbs all of this for free and ported to ARM with constant edits only (3 shells in 3 runs). The fixed-address variant demonstrates nothing Path C doesn't and breaks on every rebuild, so I left it out of the paths I stand behind. I am keeping the artifacts only as a worked example of how a build-specific heap groom decays, which is the real lesson: an offset is a fact about one binary, not about the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mitigations, mapped to what they actually buy you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Upgrade to 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Removes both CVEs at the source: no more bailout corruption, no more dirty-state reuse. This is the fix; everything below is defense in depth.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Windows: the &lt;code&gt;-1&lt;/code&gt; rebuild&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Adds Brotli 1.2.1 for CVE-2026-111497, a separate cURL-reachable OOB write. Same PHP runtime, updated dependency.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.upload_progress.enabled=0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes the pre-RINIT session consumer the FPM and mod_php session chains rely on. PHP's own stopgap. Does not touch path1.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mod_php → php-fpm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Removes the startup-failure class and isolates workers. Not a cure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disable unused extensions and settings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The habit that pays on the next unknown bug. Every decoder you don't load is a decoder that can't be confused, and the same goes for INI switches you do not need: turn off &lt;code&gt;cgi.fix_pathinfo&lt;/code&gt;, &lt;code&gt;expose_php&lt;/code&gt;, and any feature your application never uses.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Confine the worker (AppArmor / SELinux / seccomp)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A profile that stops the PHP worker from spawning a shell blocks the &lt;code&gt;system()&lt;/code&gt; payload, and it bites where &lt;code&gt;disable_functions&lt;/code&gt; cannot: this bug calls the internal handler by address, so &lt;code&gt;disable_functions&lt;/code&gt; is bypassed (I confirmed it fires with &lt;code&gt;system&lt;/code&gt; disabled). Docker makes a tailored profile cheap to apply, though the default Docker profile still allows shell exec. Defense in depth, not a cure: a worker running attacker code can still &lt;code&gt;readfile()&lt;/code&gt; your &lt;code&gt;.env&lt;/code&gt;, keys, and source through PHP and hand them back in the response, no shell needed. Only the upgrade removes the bug.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How this was done, honestly
&lt;/h2&gt;

&lt;p&gt;A closing note on the effort, because the "hard even with LLMs" line up top deserves receipts. This is the story of actually building the thing, dead-ends and all.&lt;/p&gt;

&lt;p&gt;I did not find this bug, and my first swing at it missed. The night before the patch I heard about it and spent a few late hours trying to reason it out. I got as far as the two parts that matter, a &lt;code&gt;longjmp&lt;/code&gt; on OOM and &lt;code&gt;memory_limit&lt;/code&gt;, and then told myself the wrong story. I was certain it had to route through serialization and the internal flags around it, so that is where I dug, and that is where I stayed stuck. Some of the information floating around online pointed the same wrong way, and I took it at face value. I'll blame the late hour rather than look too hard at my own incompetence 😆.&lt;/p&gt;

&lt;p&gt;Then the patch landed, the shape of the thing got clearer, and the OCD kicked in. I wanted the full what, why, and how, so I set out to reproduce it end to end. The process was deliberate. I wrote up my own analysis first, a long guide and prompt laying out my theory of where the bug lived, how to reproduce it, and the path from there to a shell, then handed the exploration to &lt;strong&gt;DeepSeek V4.1 Flash 1017&lt;/strong&gt;, under one firm rule: when it got stuck or unsure, it stopped and asked me how to proceed instead of guessing. At the dead-ends I gave direction and, a few times, code. We iterated over several days. Whole approaches looked right and went nowhere. The model was a regular, non-abliterated release. No jailbreak, no uncensored fork. A mainstream aligned model, pointed at a patched vulnerability, with a human in the loop the whole way.&lt;/p&gt;

&lt;p&gt;The final validation and this write-up were mine, with Claude Opus. That last mile was the real test of the "hard even with LLMs" claim in the TLDR. Getting a shell on a known-vulnerable container is one thing. Getting the &lt;em&gt;same&lt;/em&gt; chain to fire reliably, re-deriving every offset by hand for a new build, porting it from x86-64 to an ARM machine, that is where the days went. The AI accelerated the work. It did not trivialize it.&lt;/p&gt;

&lt;p&gt;One more thing on the effort, because it matters. I am a PHP core developer. I have been release manager for over 20 PHP releases, I know my way around the engine internals, and I have spent years on the security side. I am not a neophyte poking at a black box, and it still took days of guided work, wrong turns, and hand re-derivation to reach a reliable shell. So when someone tells you they typed "find me an exploit" into a chatbot and out came a working php2shell chain, they are, quite frankly, full of shit. The prompt is not the hard part. The offsets are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this bug is, and what it isn't
&lt;/h2&gt;

&lt;p&gt;php2shell is a genuine unauthenticated RCE, and if you run an unpatched PHP you should treat it as one and upgrade today. It is also a reminder that the distance between "a bug exists" and "a stranger owns your box" is measured in offsets, heap state, and days of patient grooming. The patch deletes the bug the whole path is built on, so there is no path left to walk. That is the only outcome that matters. The researchers who found it did the hard part; the PHP team shipped the fix fast; and the footnote is that even with a capable model doing the grinding, turning this into a reliable shell was work. That gap is not a reason to relax. It is why the upgrade is the whole answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Corrections welcome
&lt;/h3&gt;

&lt;p&gt;This gets more accurate when people who know more poke holes in it. If I missed a path, botched an offset, or told the story wrong, tell me: email &lt;a href="mailto:ilia@ilia.ws"&gt;ilia@ilia.ws&lt;/a&gt;, DM me on X at &lt;a href="https://x.com/iliaa" rel="noopener noreferrer"&gt;@iliaa&lt;/a&gt;, or find me in the usual Discords :P.&lt;/p&gt;

&lt;p&gt;Reproduction harness, per-path notes, and the full offset tables live in the companion repository, &lt;a href="https://github.com/iliaal/php2shell" rel="noopener noreferrer"&gt;github.com/iliaal/php2shell&lt;/a&gt;, which targets the vulnerable 8.5.11 images only. Patched releases are the control in every test.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Seventeen Seconds to Parse One Name: A Security Scan Across Eleven PHP Projects</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:32:13 +0000</pubDate>
      <link>https://dev.to/iliaa/seventeen-seconds-to-parse-one-name-a-security-scan-across-eleven-php-projects-2251</link>
      <guid>https://dev.to/iliaa/seventeen-seconds-to-parse-one-name-a-security-scan-across-eleven-php-projects-2251</guid>
      <description>&lt;p&gt;This is a 16 KB name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Smith, '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;str_repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'V '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8188&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Iliaal\NameParser\Parser&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On nameparser 1.5.0, the last version before the fix, that call takes 17.4 seconds of CPU on my i9-13950HX. On 1.7.0 it takes 0.064 seconds. Each &lt;code&gt;V&lt;/code&gt; is a candidate roman-numeral suffix, and the old suffix scan walked back over every earlier token for each one, so the cost grew with the square of the input. Nothing about it is exotic. It is a plausible shape for a junk row in a CSV import, and it pins a PHP worker for as long as the row keeps coming.&lt;/p&gt;

&lt;p&gt;I didn't find it. A security scan did, and the same scan ran across all ten of my PHP extensions as well. In the first three weeks of October each project got a full pass: one coordinating session, a set of subagents reading code and writing proof-of-concept exploits, and a separate verifier that re-ran every finding before it counted, on its own ASan and release builds for the C code. Everything below ships in releases from the last few weeks.&lt;/p&gt;

&lt;p&gt;Two other things landed in the same window. PHP's October security releases changed what &lt;code&gt;memory_limit&lt;/code&gt; does, which forced code changes in three extensions and a same-day round of re-releases in five more. And DuckDB 2.0 went GA; pdo_duckdb bundles it as of today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scans found, and what is fixed
&lt;/h2&gt;

&lt;p&gt;Every project in the table has a release that carries its fixes. The counts are findings confirmed by the verifier, fixed, and released; severities are the verifier's, not the scanner's first guess.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Fixed in&lt;/th&gt;
&lt;th&gt;Confirmed and fixed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/phpser/releases/tag/0.7.1" rel="noopener noreferrer"&gt;phpser&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.7.1&lt;/td&gt;
&lt;td&gt;19: 1 high, 5 medium, 13 low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/mdparser/releases/tag/0.8.0" rel="noopener noreferrer"&gt;mdparser&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.8.0&lt;/td&gt;
&lt;td&gt;12: 1 high, 5 medium, 5 low, 1 informational&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/fastchart/releases/tag/1.8.0" rel="noopener noreferrer"&gt;fastchart&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1.8.0&lt;/td&gt;
&lt;td&gt;15 reproduced, highest medium, including vendored plutovg and plutosvg; two UBSan sites in vendored code only partly addressed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/php_clickhouse/releases/tag/0.13.0" rel="noopener noreferrer"&gt;php_clickhouse&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.13.0&lt;/td&gt;
&lt;td&gt;1 high, 1 medium, plus low-severity binding fixes and 7 server-triggered crashes in clickhouse-cpp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/pdo_duckdb/releases/tag/0.8.0" rel="noopener noreferrer"&gt;pdo_duckdb&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.8.0&lt;/td&gt;
&lt;td&gt;6: 1 high, 1 medium, 1 low-medium, 2 low, 1 informational&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/fastjson/releases/tag/0.10.0" rel="noopener noreferrer"&gt;fastjson&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.10.0&lt;/td&gt;
&lt;td&gt;4: 1 medium, 3 low, plus 1 documentation correction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/php_excel/releases/tag/2.9.0" rel="noopener noreferrer"&gt;php_excel&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;2.9.0&lt;/td&gt;
&lt;td&gt;5: 3 low, 2 informational&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/statgrab/releases/tag/2.3.0" rel="noopener noreferrer"&gt;statgrab&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;2.3.0&lt;/td&gt;
&lt;td&gt;4: 2 medium, 2 low, plus a build bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/fast_uuid/releases/tag/0.8.1" rel="noopener noreferrer"&gt;fast_uuid&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.8.1&lt;/td&gt;
&lt;td&gt;5: 4 low, 1 by-design behaviour changed anyway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/phonetic/releases/tag/0.4.4" rel="noopener noreferrer"&gt;phonetic&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;0.4.4&lt;/td&gt;
&lt;td&gt;3: 1 medium, 2 low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/iliaal/nameparser/releases/tag/1.6.0" rel="noopener noreferrer"&gt;nameparser&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1.6.0&lt;/td&gt;
&lt;td&gt;5: 1 medium, 1 low, 3 informational&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few findings in upstream libraries are reported privately to their maintainers and aren't described here until fixes ship.&lt;/p&gt;

&lt;p&gt;The coordinating model was Mythos 5.1, but most of the work ran on other models: on fastjson, 695 of the 702 assistant messages came from Opus 5.5 subagents doing the reading, fuzzing, and PoC writing. The phpser scan doubled as an A/B/C test of my own tooling against a bare prompt, which I &lt;a href="https://x.com/iliaa/status/2109324437501718830" rel="noopener noreferrer"&gt;wrote up on X&lt;/a&gt; at the time. So the table measures the process more than any one model.&lt;/p&gt;

&lt;p&gt;fast_uuid and phonetic came back with zero memory-safety bugs, and php_excel's wrapper with nothing above low severity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bugs that needed no attacker
&lt;/h3&gt;

&lt;p&gt;The worst php_clickhouse finding in practice wasn't the high-severity one. It was B-4, which corrupted data on ordinary queries. Decimal and DateTime64 decoding cached each column's scale in a memo keyed on the raw address of the column's type object. clickhouse-cpp allocates a fresh type object per column per block and frees the block after each callback, so the next block's first column could land at the same address and inherit the previous column's scale. Select a scale-2 column next to a scale-6 one with &lt;code&gt;max_block_size=100&lt;/code&gt;, and a stored &lt;code&gt;12.34&lt;/code&gt; came back as &lt;code&gt;0.001234&lt;/code&gt;, with no error. With default settings the verifier still saw 1 wrong cell in 70,000 rows.&lt;/p&gt;

&lt;p&gt;The test suite never caught it because ASan builds hide it: the sanitizer's quarantine stops freed memory from being reused, so the address never repeats. It took a release build and a real server. 0.13.0 keys the memo correctly.&lt;/p&gt;

&lt;p&gt;0.13.0 also fixes a behaviour you should check for before upgrading. A whole-number insert into a Decimal column, &lt;code&gt;7&lt;/code&gt; into &lt;code&gt;Decimal(9,2)&lt;/code&gt;, now stores &lt;code&gt;7.00&lt;/code&gt; as the server does; 0.12 stored &lt;code&gt;0.07&lt;/code&gt;. Code that compensated by passing pre-scaled integers (&lt;code&gt;700&lt;/code&gt; meaning 7.00) will now store &lt;code&gt;700.00&lt;/code&gt; without complaint.&lt;/p&gt;

&lt;p&gt;fast_uuid's main finding has the same character. &lt;code&gt;uuid1()&lt;/code&gt; and &lt;code&gt;uuid6()&lt;/code&gt; with a fixed node and clock sequence kept no last-tick state, so &lt;code&gt;uuid1(1, 7)&lt;/code&gt; called 500,000 times produced 61,723 duplicates. The default arguments were fine. 0.8.1 fixes it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bugs a server, a file, or a document could trigger
&lt;/h3&gt;

&lt;p&gt;php_clickhouse's high-severity finding was a heap overflow write. With compression enabled, a malicious or compromised server could send a compressed frame whose declared size was smaller than the 9-byte frame header, and the client wrote past its buffer with bytes the server chose. clickhouse-cpp had already fixed it on master in &lt;a href="https://github.com/ClickHouse/clickhouse-cpp/pull/566" rel="noopener noreferrer"&gt;PR #566&lt;/a&gt;, but no clickhouse-cpp release contains the fix yet, and php_clickhouse's prebuilt binaries ship the library. 0.13.0 carries the patch. The same release rejects seven more server-triggered crash shapes, from &lt;code&gt;FixedString(0)&lt;/code&gt; columns to &lt;code&gt;Array&lt;/code&gt; offsets that run backwards, and stops a packet of a few dozen bytes from committing gigabytes of memory outside &lt;code&gt;memory_limit&lt;/code&gt;. I sent the fixes upstream as 15 &lt;a href="https://github.com/ClickHouse/clickhouse-cpp/pulls?q=author%3Ailiaal" rel="noopener noreferrer"&gt;pull requests&lt;/a&gt;; three have merged so far and twelve are open.&lt;/p&gt;

&lt;p&gt;fastchart's two medium findings were both resource bombs. A 44 KB SVG passed to &lt;code&gt;svgTo*()&lt;/code&gt; reached 2,121,264 KiB of resident memory through data URIs and &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; references that slipped past the input gate, all of it outside &lt;code&gt;memory_limit&lt;/code&gt;; a 30,000-deep &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; chain crashed the process. A 1 MB CgBI PNG with decoy dimensions decoded into a 16000 by 16000 buffer. 1.8.0 runs the gate on the parsed tree, bounds &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; depth and expansion, and caps decoded images at 4,096 pixels a side. Some heavy but legitimate SVG that used to render slowly now throws a &lt;code&gt;ValueError&lt;/code&gt; instead, so test your largest inputs.&lt;/p&gt;

&lt;p&gt;mdparser's high-severity finding was quadratic parsing: blank lines inside deeply nested lists took 1.7 seconds at 200 KB and 55 seconds at 1 MB. Seven of mdparser's thirteen findings had their root cause in md4c rather than in my code, and the fixes went upstream; md4c merged five of my pull requests for them on October 13. One remaining low-severity signed overflow is still waiting in &lt;a href="https://github.com/mity/md4c/pull/374" rel="noopener noreferrer"&gt;md4c#374&lt;/a&gt;. 0.8.0 also changes generated heading IDs to start with &lt;code&gt;user-content-&lt;/code&gt;, which closes a DOM-clobbering finding and breaks existing links to heading fragments when &lt;code&gt;headingAnchors&lt;/code&gt; is on.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other high and medium findings
&lt;/h3&gt;

&lt;p&gt;pdo_duckdb's high finding was a use-after-free: an appender left open inside a reference cycle could outlive its PDO handle when &lt;code&gt;gc_collect_cycles()&lt;/code&gt; or request shutdown freed the handle first. Two of the medium-range findings were the same shape. A &lt;code&gt;memory_limit&lt;/code&gt; bailout at the wrong moment during column setup left uninitialised type pointers that were destroyed later; the PoC got the sanitizer to report a destructor running on &lt;code&gt;0x4141414141414141&lt;/code&gt;. 0.8.0 fixes all six.&lt;/p&gt;

&lt;p&gt;phpser's high finding was that a nested SPL &lt;code&gt;unserialize()&lt;/code&gt; ignored &lt;code&gt;allowed_classes&lt;/code&gt;. Its legacy handler path ran outside the decoder's context, so a class allowlist set on the outer call never reached the inner one. 0.7.1 fixes it along with eighteen others, most of them bounds on work an untrusted payload could demand.&lt;/p&gt;

&lt;p&gt;nameparser's quadratic case, from the top of this post, is fixed in 1.6.0. Its release notes say plainly that earlier notes describing hostile-input parsing as linear "did not hold for these shapes". phonetic's medium finding was a 14-byte input, &lt;code&gt;bmpm("de akeaxhaeasz")&lt;/code&gt;, that caused an uncatchable fatal error. 0.4.4 turns budget overruns into a catchable &lt;code&gt;ValueError&lt;/code&gt; and caps BMPM output at 256 times the input size, after a 1,560-byte input had produced 3.9 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  memory_limit stopped stopping things
&lt;/h2&gt;

&lt;p&gt;PHP 8.2.35, 8.3.36, 8.4.27, and 8.5.12 shipped on October 21 with the fix for &lt;a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" rel="noopener noreferrer"&gt;CVE-2026-109618&lt;/a&gt;, rated critical. Part of that fix changes what happens when an allocation crosses &lt;code&gt;memory_limit&lt;/code&gt;. The engine used to bail out on the spot with a longjmp. Now it records the error, lets the allocation succeed, and raises the fatal at the next safe point in the VM.&lt;/p&gt;

&lt;p&gt;For PHP code that's invisible. For an extension it isn't. A C loop that decodes a payload or builds rows never reaches a VM safe point, so it keeps allocating past &lt;code&gt;memory_limit&lt;/code&gt; until it hits a hard limit, by default twice &lt;code&gt;memory_limit&lt;/code&gt; below 1 GiB. At the hard limit the engine calls &lt;code&gt;_exit()&lt;/code&gt;: no shutdown functions, no destructors, no session write, and php-fpm loses the worker instead of failing one request.&lt;/p&gt;

&lt;p&gt;So every loop whose length comes from input has to notice the pending error and stop on its own. This is how phpser does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12 queue a memory_limit error instead of
 * bailing out; the allocation still succeeds until a hard limit (2x the soft
 * limit below 1G) that _exit()s the process. Loops that keep allocating must
 * stop on their own once the error is pending. */&lt;/span&gt;
&lt;span class="cp"&gt;#ifdef ZEND_HAS_OOM
# define PHPSER_HAS_OOM() ZEND_HAS_OOM()
#else
# define PHPSER_HAS_OOM() 0
#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A loop check cannot help when a single allocation is sized from a count the payload declares. Those go through the new fallible allocators instead (&lt;code&gt;zend_hash_real_finit(..., true)&lt;/code&gt;, &lt;code&gt;safe_femalloc&lt;/code&gt;, &lt;code&gt;fecalloc&lt;/code&gt;), which queue the error and return &lt;code&gt;NULL&lt;/code&gt; rather than running into the hard limit. phpser 0.10.0 uses both. mdparser 0.8.1 and pdo_duckdb 0.9.0 do the same for their input-sized loops and presizes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The re-release day
&lt;/h3&gt;

&lt;p&gt;There is a catch. The October headers route &lt;code&gt;smart_str&lt;/code&gt; growth through a new engine function, &lt;code&gt;smart_str_ferealloc()&lt;/code&gt;, from inline header code. An extension compiled against 8.4.27 headers imports that symbol even if it never mentions it, and 8.4.26 doesn't export it. My release workflows built prebuilt binaries against the newest patch of each PHP minor, which had always been the safe choice.&lt;/p&gt;

&lt;p&gt;On October 26 that meant mdparser 0.8.1, phpser 0.10.0, fastjson 0.10.1, php_clickhouse 0.13.1, and phonetic 0.4.5 all shipped Linux and macOS binaries that died with &lt;code&gt;undefined symbol: smart_str_ferealloc&lt;/code&gt; on every PHP 8.4 before 8.4.27 and every 8.5 before 8.5.12. Source builds were fine, because they compile against the PHP they load into. All five were re-released the same day as mdparser 0.8.2, phpser 0.10.1, fastjson 0.10.2, php_clickhouse 0.13.2, and phonetic 0.4.6, and fastchart 1.8.1 picked up the same change before it could break. Their prebuilts now compile against the oldest patch of each minor (8.4.1, since 8.4.0 was never released, and 8.5.0), and each binary has to load on both the oldest and the newest patch before anything uploads.&lt;/p&gt;

&lt;p&gt;That fix has a cost, and the phpser changelog states it: a binary built against 8.4.1 headers doesn't have the &lt;code&gt;memory_limit&lt;/code&gt; stop. To get it, you build from source on a patched PHP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Refusing vulnerable PHP releases
&lt;/h3&gt;

&lt;p&gt;The longer-term answer is the opposite trade. pdo_duckdb 0.9.0 makes it today: its prebuilt binaries are built against the patched headers and require PHP 8.2.35, 8.3.36, 8.4.27, or 8.5.12 or newer. Load one on 8.4.26 and the process stops at the first call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;php: symbol lookup error: /ext/pdo_duckdb.so: undefined symbol: smart_str_ferealloc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message is honest but unfriendly, and it arrives at runtime rather than at startup. The next releases of pdo_duckdb, phpser, and mdparser fix that with a startup check. On PHP 8.3 and newer, the module compares the running patch with the one it was compiled against and refuses to start on an older one, naming both versions. This is mdparser's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;php_version_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;PHP_VERSION_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;zend_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;E_CORE_WARNING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"mdparser was built for PHP %s and cannot run on PHP %s; "&lt;/span&gt;
        &lt;span class="s"&gt;"install a build for this PHP version or build from source"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PHP_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;php_version&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;FAILURE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that check in place, pdo_duckdb keeps the binaries it already builds against the patched headers, and phpser and mdparser can move theirs back to the patched headers and regain the &lt;code&gt;memory_limit&lt;/code&gt; stop. A server still on 8.4.26 gets a clear startup warning instead of a crash on the first request, and with a critical CVE fixed in 8.4.27 it should move anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  pdo_duckdb 0.9.0: DuckDB 2.0
&lt;/h2&gt;

&lt;p&gt;DuckDB 2.0.0 went GA on October 21, and pdo_duckdb 0.9.0, out today, bundles it in every prebuilt binary. 0.8.0 had already built against the 2.0 previews. Source builds still work against DuckDB 1.5.3 and newer; &lt;code&gt;configure&lt;/code&gt; detects 2.0 from &lt;code&gt;duckdb_v2.h&lt;/code&gt;, because the 2.0 headers still report C API version 1.5.6, and it refuses to build when 2.0 headers meet an older &lt;code&gt;libduckdb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The upgrade is not transparent, and the README has a full section on it. The one most likely to bite is the file format. DuckDB 2.0 writes new files at storage version 69, and DuckDB 1.5 reads only 64 to 68. If a 1.5 process has to read the file too, ask for the older format when you open it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PDO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'duckdb:/data/shared.duckdb;storage_compatibility_version=v1.5.6'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the 0.9.0 binary, a file opened that way reports &lt;code&gt;v1.5.0+&lt;/code&gt; in &lt;code&gt;duckdb_databases()&lt;/code&gt;, and a default one reports &lt;code&gt;v2.0.0+&lt;/code&gt;. Existing 1.5 files keep their format after 2.0 writes to them. The rest is DuckDB's own behaviour: a failed statement now invalidates the whole transaction, integer division by zero throws, single-arrow lambdas are rejected, and the new PEG parser is the default. That last one has a sharp edge for parameter binding: &lt;code&gt;$1_0&lt;/code&gt; means parameter 10 under PEG and parameter 1 under the legacy parser. Each change has a setting that restores 1.5 behaviour.&lt;/p&gt;

&lt;p&gt;0.9.0 also adds &lt;code&gt;PDO::DUCKDB_ATTR_NATIVE_STRUCTURES&lt;/code&gt;, an opt-in that fetches LIST, ARRAY, STRUCT, TUPLE, MAP, UNION, JSON, and VARIANT values as lossless PHP structures instead of canonical strings, with exact numerics, binary keys, and SQL &lt;code&gt;NULL&lt;/code&gt; kept distinct from VARIANT null. VARIANT works top-level and nested, and the appender accepts PHP arrays and objects for it.&lt;/p&gt;

&lt;p&gt;One fix in 0.9.0 matters even if you never touch 2.0: two PDO connections to the same database file could lose writes and see stale reads. Equivalent paths now share one DuckDB instance while transactions stay connection-local.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance, with the hardware attached
&lt;/h2&gt;

&lt;p&gt;Most of the speed work came before the scans. Apart from the nameparser row, which I re-ran for this post, these are the numbers from each project's changelog with the conditions it states.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Measured on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nameparser 1.6.0&lt;/td&gt;
&lt;td&gt;16 KB hostile row: 17.4 s to 0.064 s&lt;/td&gt;
&lt;td&gt;i9-13950HX, PHP 8.4.27 (my rerun of 1.5.0 vs 1.7.0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phpser 0.6.0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;range(0, 9999)&lt;/code&gt; encodes to 7 bytes; encode 57% faster, decode 66% faster&lt;/td&gt;
&lt;td&gt;aarch64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phpser 0.7.0&lt;/td&gt;
&lt;td&gt;Signed decode 53 to 85% faster; 1,000-row rowset decodes in 429 KB instead of 645 KB&lt;/td&gt;
&lt;td&gt;aarch64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastjson 0.9.0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fastjson_validate()&lt;/code&gt; costs 0.47 to 0.90x a full parse, down from 1.1 to 1.9x&lt;/td&gt;
&lt;td&gt;Neoverse-N1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fastjson 0.9.0 to 0.10.0&lt;/td&gt;
&lt;td&gt;Key-heavy decode peak memory about 15% lower; file decode peak 9 to 33% lower&lt;/td&gt;
&lt;td&gt;Neoverse-N1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fast_uuid 0.8.0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;uuid_v4()&lt;/code&gt; 76 to 42 ns; &lt;code&gt;uuid_v4_bin_batch(100)&lt;/code&gt; 61 to 27 ns per UUID; &lt;code&gt;uuid_v7_bin_batch()&lt;/code&gt; 25.9 to 16.6 ns&lt;/td&gt;
&lt;td&gt;Neoverse-N1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mdparser 0.6.2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;toAst()&lt;/code&gt; 5 to 9% faster; retained AST memory 47% lower on softbreak-dense input; code-heavy &lt;code&gt;toXml()&lt;/code&gt; output 49% smaller in memory&lt;/td&gt;
&lt;td&gt;aarch64, PHP 8.4.25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;php_clickhouse 0.12.0&lt;/td&gt;
&lt;td&gt;Temporal, string, and IP inserts about 19% faster; IPv4 selects about 12%&lt;/td&gt;
&lt;td&gt;200,000-row probes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The phpser integer-run encoding is the one I'd point at. A run with a constant step is stored as a start, a step, and a count, so &lt;code&gt;range(0, 9999)&lt;/code&gt; encodes to 7 bytes and &lt;code&gt;range(0, 999999)&lt;/code&gt; to 8. Native &lt;code&gt;serialize()&lt;/code&gt; needs 137,790 bytes for the first one.&lt;/p&gt;

&lt;p&gt;fast_uuid's speedup comes from refilling its random buffer with a userspace AES-256-CTR generator with fast key erasure, instead of a &lt;code&gt;getrandom()&lt;/code&gt; call per 8 KiB, on CPUs with AES instructions. It has one caveat, and the changelog states it: clones of one VM snapshot can repeat up to 64 KiB of output per thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking changes worth reading before you upgrade
&lt;/h2&gt;

&lt;p&gt;Most of these releases are drop-in. These are not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;php_clickhouse 0.13.0: whole-number Decimal inserts scale like the server's (above).&lt;/li&gt;
&lt;li&gt;mdparser 0.8.0: heading IDs carry a &lt;code&gt;user-content-&lt;/code&gt; prefix.&lt;/li&gt;
&lt;li&gt;phpser 0.10.0: Closures and other unserializable objects throw instead of quietly encoding &lt;code&gt;NULL&lt;/code&gt;. Check what you put in &lt;code&gt;$_SESSION&lt;/code&gt; before upgrading. New wire tags since 0.7.0 mean readers must upgrade before writers.&lt;/li&gt;
&lt;li&gt;php_excel 2.9.0 and 2.9.1: dates use PHP's timezone rather than the OS one, and text is UTF-8 regardless of the process locale. Apps that relied on the locale to pass non-UTF-8 bytes through will see different output. 2.7.0 also rejects a font from a different &lt;code&gt;ExcelBook&lt;/code&gt; in &lt;code&gt;addText()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;fastchart 1.8.0: heavy SVG that used to render slowly may now throw &lt;code&gt;ValueError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;phonetic 0.4.4: oversized BMPM and Daitch-Mokotoff work throws a catchable &lt;code&gt;ValueError&lt;/code&gt; rather than ending the request.&lt;/li&gt;
&lt;li&gt;statgrab 2.2.2: &lt;code&gt;Statgrab::ERROR_UNSUPPORTED&lt;/code&gt; is 44, matching libstatgrab; it was 41.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the feature side, phpser 0.8.0 and 0.9.0 added signed sessions (&lt;code&gt;phpser.session_key&lt;/code&gt;, HMAC-SHA256), an APCu serializer (&lt;code&gt;apc.serializer=phpser&lt;/code&gt;), keyed payloads, and an exported C API. nameparser 1.4.0 understands joint salutations, so &lt;code&gt;Mr. and Mrs. Brad Smith&lt;/code&gt; keeps &lt;code&gt;Brad&lt;/code&gt; as the first name, with new &lt;code&gt;isJoint()&lt;/code&gt;, &lt;code&gt;getSalutations()&lt;/code&gt;, and &lt;code&gt;getPartner()&lt;/code&gt;, and 1.4.1 raised first-and-last accuracy on 30,000 NPPES provider rows from 95.94% to 97.18%. statgrab 2.3.1 fixes a heap overflow in the bundled libstatgrab's Linux command-line handling that the scan didn't cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a scan is worth
&lt;/h2&gt;

&lt;p&gt;None of these projects were neglected before October. Each had a test suite and documentation that said where its limits were. The scans still found an allowlist that didn't reach a nested call, a Decimal that came back ten thousand times too small, and a 16 KB name that took 17 seconds. Two of the most consequential findings sat in code I had already called linear or already tested under ASan, and for the Decimal bug the ASan testing is exactly what hid it.&lt;/p&gt;

&lt;p&gt;The fixes are released and listed above. Upgrade pdo_duckdb if you are moving to DuckDB 2.0, php_clickhouse if you talk to a server you don't control, and PHP itself to 8.4.27 or 8.5.12 whatever else you do.&lt;/p&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>CodeSage: Code Intelligence for AI Agents, and Why I Rebuilt Its Models</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Fri, 02 Oct 2026 11:23:47 +0000</pubDate>
      <link>https://dev.to/iliaa/codesage-code-intelligence-for-ai-agents-and-why-i-rebuilt-its-models-2081</link>
      <guid>https://dev.to/iliaa/codesage-code-intelligence-for-ai-agents-and-why-i-rebuilt-its-models-2081</guid>
      <description>&lt;p&gt;Until this week, &lt;a href="https://github.com/iliaal/codesage" rel="noopener noreferrer"&gt;CodeSage&lt;/a&gt; ran its two models exactly as they ship on Hugging Face: Jina's code embedder and the ms-marco MiniLM reranker. A full index of php-src took 1,782 seconds, just under half an hour, and I got tired of waiting.&lt;/p&gt;

&lt;p&gt;CodeSage 0.38 does the same full index in 373 seconds. Nothing was retrained. The weights are the same weights; what changed is the inference graph around them, a few things in the chunker, and one SQLite query that had been quietly eating most of the run. The two rebuilt models are now public on Hugging Face, along with the scripts that produce them byte-for-byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CodeSage gives an agent
&lt;/h2&gt;

&lt;p&gt;CodeSage is a code intelligence engine for AI coding agents: a structural graph (symbols, references, dependencies) plus semantic search (embedding retrieval with cross-encoder reranking), in one Rust binary that works as a CLI or an MCP server. An agent asks it questions grep can't answer, like "what depends on this class" or "where does session handling happen", and gets file-and-line answers from an index instead of guesses from a context window.&lt;/p&gt;

&lt;p&gt;Parsing is tree-sitter, covering PHP, Python, C, C++, Java, Rust, JavaScript, TypeScript and Go. Each project gets one SQLite database at &lt;code&gt;.codesage/index.db&lt;/code&gt;, with sqlite-vec for KNN and FTS5 for literal matches. The MCP side is a stdio shim that starts or reuses a per-user daemon over a Unix socket, so concurrent agent sessions share one project cache, one model pool and one CUDA context. There is no HTTP listener.&lt;/p&gt;

&lt;p&gt;The MCP server exposes 22 tools. The ones agents lean on most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;find_symbol&lt;/code&gt;, &lt;code&gt;find_references&lt;/code&gt;, &lt;code&gt;trace_call_path&lt;/code&gt;: where something is defined, who calls it, and the shortest call chain between two symbols.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;impact_analysis&lt;/code&gt;, &lt;code&gt;assess_risk&lt;/code&gt;: what a change to this file can break, scored from the graph plus git history.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search&lt;/code&gt;: natural-language retrieval over ~50-line chunks, reranked.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;from_trace&lt;/code&gt;: maps a pasted stack trace or ASan report (Python, PHP with Xdebug, Rust, Java, Go, Node, gdb) onto indexed symbols.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;edit_check&lt;/code&gt;: diffs a proposed declaration against HEAD before the agent writes the file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The graph is only as useful as its recall. In August, &lt;code&gt;impact_analysis&lt;/code&gt; reported that &lt;code&gt;AxiosError&lt;/code&gt; in axios had 0 dependents. It has 23. File-path imports like &lt;code&gt;./core/AxiosError.js&lt;/code&gt; never resolved; only Rust-style &lt;code&gt;::&lt;/code&gt; paths did. Fixing that, and counting &lt;code&gt;Foo.staticMethod()&lt;/code&gt; and &lt;code&gt;x instanceof Foo&lt;/code&gt; as usage, took mean dependent recall on axios from 0.37 to 0.66 at precision 0.85. An agent told "0 dependents" by a tool it trusts goes ahead and changes the class.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index has to keep up with the code
&lt;/h2&gt;

&lt;p&gt;An index that lags the code feeds the agent stale answers with full confidence, so CodeSage re-indexes from git hooks on every commit, checkout and merge. That puts the indexer's fixed cost on the hot path dozens of times a day, which is why a lot of the work across 46 releases since April has gone into cost rather than features.&lt;/p&gt;

&lt;p&gt;The per-commit path got there first. In 0.23.0, a no-change incremental run stopped loading the ONNX model and mapping a CUDA context before checking whether any file had changed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;No-change incremental &lt;code&gt;codesage index&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.22.0&lt;/td&gt;
&lt;td&gt;2.7-4.5 s&lt;/td&gt;
&lt;td&gt;1.16 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.23.0&lt;/td&gt;
&lt;td&gt;0.08 s&lt;/td&gt;
&lt;td&gt;150 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The file watcher re-embeds only chunks whose text changed: an 8-save burst marks 90 chunks dirty and embeds 1.&lt;/p&gt;

&lt;p&gt;That leaves the full index, which nobody runs on every commit but everybody runs eventually: first onboarding, a model change, a chunker change. 0.38 itself forces one, because the embedder now reads up to 1,024 tokens per chunk instead of 512. When the full run takes half an hour on a large repo, people postpone it, and a postponed re-index is a stale index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same weights, rebuilt graphs
&lt;/h2&gt;

&lt;p&gt;The embedder was slow because of how it was exported, not because of what it computes. The upstream Jina v2 ONNX file is a decomposed opset-11 export. Its attention uses ALiBi plus a QK LayerNorm, a combination ONNX Runtime's transformer optimizer doesn't recognize, so nothing fuses. Every layer materializes the full &lt;code&gt;[batch, 12, seq, seq]&lt;/code&gt; score tensor several times over, and because the graph outputs per-token hidden states, every batch copies a &lt;code&gt;[batch, seq, 768]&lt;/code&gt; tensor from the GPU back to the host just so the caller can average it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/IA0x00/jina-embeddings-v2-base-code-codesage" rel="noopener noreferrer"&gt;IA0x00/jina-embeddings-v2-base-code-codesage&lt;/a&gt; carries two graphs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;onnx/model.onnx&lt;/code&gt; (fp32, every execution provider): masked mean pooling and L2 normalization folded into the graph, so the output is the 768-dimension sentence embedding and nothing else crosses back to the host.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onnx/model_cuda_fp16.onnx&lt;/code&gt; (CUDA only): the same pooling, plus attention fused by hand into &lt;code&gt;com.microsoft&lt;/code&gt; &lt;code&gt;MultiHeadAttention&lt;/code&gt;, with ALiBi and the key-padding mask passed as &lt;code&gt;attention_bias&lt;/code&gt;. Weights are fp16; the output stays fp32.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One fp16 detail: the attention mask filled padded positions with &lt;code&gt;-FLT_MAX&lt;/code&gt;, which overflows fp16. It's &lt;code&gt;-10000.0&lt;/code&gt; now, which is still negative enough to zero those positions after softmax.&lt;/p&gt;

&lt;p&gt;Measured on an RTX 4080 Laptop GPU with ONNX Runtime 1.24.4, 2,048 real chunks per corpus of up to 512 tokens each, compared against upstream fp32 with pooling done on the host:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Corpus&lt;/th&gt;
&lt;th&gt;Upstream fp32&lt;/th&gt;
&lt;th&gt;Pooled fp32&lt;/th&gt;
&lt;th&gt;Fused fp16&lt;/th&gt;
&lt;th&gt;Min cosine (fp16)&lt;/th&gt;
&lt;th&gt;Top-10 neighbour overlap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;php-src (C)&lt;/td&gt;
&lt;td&gt;59.7 chunks/s&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;249-257&lt;/td&gt;
&lt;td&gt;0.99999&lt;/td&gt;
&lt;td&gt;0.994&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel app (PHP)&lt;/td&gt;
&lt;td&gt;67.2&lt;/td&gt;
&lt;td&gt;113&lt;/td&gt;
&lt;td&gt;301-317&lt;/td&gt;
&lt;td&gt;0.999996&lt;/td&gt;
&lt;td&gt;0.996&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React app (TS/JS)&lt;/td&gt;
&lt;td&gt;69.8&lt;/td&gt;
&lt;td&gt;109&lt;/td&gt;
&lt;td&gt;296-317&lt;/td&gt;
&lt;td&gt;0.999995&lt;/td&gt;
&lt;td&gt;0.997&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A minimum cosine of 0.99999 across 2,048 chunks means the vectors are, for retrieval purposes, the same vectors. Those throughput figures are at the 512-token cap; CodeSage now runs Jina at 1,024, because real C in php-src runs a median of 611 tokens per 1,500-byte chunk and the old cap truncated more than half of them. So the production per-chunk rate is lower than this table, and the chunks carry more of the code.&lt;/p&gt;

&lt;p&gt;The reranker needed far less. &lt;a href="https://huggingface.co/IA0x00/ms-marco-MiniLM-L6-v2-codesage" rel="noopener noreferrer"&gt;IA0x00/ms-marco-MiniLM-L6-v2-codesage&lt;/a&gt; is a standard BERT, so ONNX Runtime's stock optimizer fuses it (embedding LayerNorm, six Attention nodes, skip LayerNorm, bias GELU) and the work was mostly converting to fp16. Scoring 50 (query, chunk) candidates went from a median 108.0 ms per query to 20.6 ms, with the same top result on 40 of 40 test queries, top-10 overlap of 0.9975, and a maximum logit change of 0.024.&lt;/p&gt;

&lt;p&gt;Both repositories are Apache-2.0, the upstream license, and the weights are unchanged apart from precision. CodeSage pins each one by Hugging Face commit and by the sha256 of every graph and tokenizer it loads, and &lt;code&gt;scripts/derive-jina-onnx.py&lt;/code&gt; and &lt;code&gt;scripts/derive-reranker-onnx.py&lt;/code&gt; in the CodeSage repo download the pinned upstream files, verify them, and write the published files byte-for-byte. You don't have to trust my upload; you can rebuild it. Your &lt;code&gt;config.toml&lt;/code&gt; keeps the upstream model names, and the pin table maps them to the derived repos, so there is nothing to change on upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  The biggest single win wasn't a model
&lt;/h2&gt;

&lt;p&gt;Most of the half hour was SQLite. When a file changes, its old chunks are deleted first, and &lt;code&gt;file_path&lt;/code&gt; lived in an auxiliary column of the sqlite-vec table and an &lt;code&gt;UNINDEXED&lt;/code&gt; column of the FTS5 table. Neither can be searched by index, so &lt;code&gt;DELETE ... WHERE file_path = ?&lt;/code&gt; scanned both tables for every file: about one second per file at php-src's 54,000 rows, roughly 20 of the 28 minutes a full index took when I measured it.&lt;/p&gt;

&lt;p&gt;The fix is a small sidecar table, &lt;code&gt;&amp;lt;chunk_table&amp;gt;_paths(id, file_path)&lt;/code&gt;, kept in step on every insert and delete and rebuilt if it goes missing or diverges. Deletes now resolve the path to rowids and remove by rowid: 3.2 ms per file.&lt;/p&gt;

&lt;p&gt;Two chunker changes ship in the same release. Chunks that are mostly digits and commas (lookup tables, timezone and Unicode data) are no longer embedded, since no natural-language query is looking for them, and batches are length-sorted and byte-bounded so a batch of short chunks isn't padded out to the length of its longest member.&lt;/p&gt;

&lt;p&gt;Before and after, all of it together:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;codesage index --full&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;0.38&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;php-src&lt;/td&gt;
&lt;td&gt;1,782 s&lt;/td&gt;
&lt;td&gt;373 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;private mobile backend&lt;/td&gt;
&lt;td&gt;469 s&lt;/td&gt;
&lt;td&gt;80 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;private frontend app&lt;/td&gt;
&lt;td&gt;295 s&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is the combined effect of the fused fp16 graph, the 1,024-token cap, the dropped data chunks, the batching, and the delete fix. I don't have a clean per-change split beyond the delete scan's own estimate, so I'm not going to invent one. Retrieval held: recall@10 is unchanged on three private eval corpora and rose from 0.765 to 0.790 on a php-src session corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scores that say what they measured
&lt;/h2&gt;

&lt;p&gt;Fast answers are worthless if the agent can't tell a measurement from a default, so several releases since August went into making CodeSage say what it didn't check. &lt;code&gt;impact_analysis&lt;/code&gt; reports a lower bound, because dynamic calls and unresolved imports can hide real dependents. &lt;code&gt;assess_risk&lt;/code&gt; returns &lt;code&gt;unscored&lt;/code&gt; for a file with no indexed git history instead of a low number. &lt;code&gt;find_references&lt;/code&gt; marks a count as a floor when the symbol name is ambiguous. An empty search result carries indexed-file counts per language, so "not found" and "never indexed" stop looking identical.&lt;/p&gt;

&lt;p&gt;The same rule applies to my own benchmarks. In August I withdrew a published retrieval number (recall@10 0.932, NDCG@10 0.788 over 602 queries) after finding the harness had indexed whole repos instead of each benchmark's root and scored crashed queries as 0.000. The replacement table in the README runs 663 queries across 33 repos in 9 languages, reports per-language NDCG@10 from 0.7455 (C) to 0.9434 (JavaScript), and says it was measured on a modified 0.27.0 build. It hasn't been re-run on 0.38 yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it falls short
&lt;/h2&gt;

&lt;p&gt;These are the limits I know about, all of them in the README:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nine languages. On the public benchmark, 47% of queries are in languages CodeSage doesn't parse (Ruby, Kotlin, Swift, Scala) and get no recall at all.&lt;/li&gt;
&lt;li&gt;Agents don't reach for it on their own. With CodeSage and &lt;code&gt;Grep&lt;/code&gt; both available in Claude Code, agents picked CodeSage on code-identifier queries 1.1% of the time over 30 days of sessions, and 0 of 10 on a controlled harness (measured in April, not re-measured since). That is why the &lt;code&gt;codesage-tools&lt;/code&gt; Claude Code plugin serves a &lt;code&gt;codesage brief&lt;/code&gt; inline before an Edit or Write in an onboarded project instead of waiting to be asked. It stays silent when there is nothing worth saying.&lt;/li&gt;
&lt;li&gt;Every speed number in this post is CUDA. CPU and Apple's CoreML get the pooled fp32 graph but not the fused fp16 one, and I haven't measured the CPU side.&lt;/li&gt;
&lt;li&gt;No prebuilt binaries and no documented Windows support. You build it with cargo.&lt;/li&gt;
&lt;li&gt;Read-only. It doesn't edit code and doesn't query across repositories.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo build &lt;span class="nt"&gt;--release&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; codesage &lt;span class="nt"&gt;--features&lt;/span&gt; cuda   &lt;span class="c"&gt;# drop --features cuda for CPU / CoreML&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your/project
codesage init
codesage index
codesage install-hooks
claude mcp add &lt;span class="nt"&gt;--scope&lt;/span&gt; user codesage &lt;span class="nt"&gt;--&lt;/span&gt; codesage mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Codex users run &lt;code&gt;codesage install codex --global&lt;/code&gt; instead of the last line. If you're on Claude Code, &lt;a href="https://github.com/iliaal/whetstone" rel="noopener noreferrer"&gt;whetstone&lt;/a&gt; is the other half: CodeSage tells the agent what the code is, whetstone covers how to investigate, review and ship. The first index downloads the models: about 370 MB on CUDA, about 736 MB for the fp32 graphs on CPU or CoreML.&lt;/p&gt;

&lt;p&gt;Nothing about CodeSage 0.38 required a better model. It required looking at what the existing one did between input and output, and at what the database did on every file delete. Most of that half hour was overhead around the model, not the model itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/iliaal/codesage" rel="noopener noreferrer"&gt;github.com/iliaal/codesage&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>mcp</category>
    </item>
    <item>
      <title>The Cost of Failing Closed: What Shipped Across Nine PHP Extensions</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Mon, 27 Jul 2026 19:19:25 +0000</pubDate>
      <link>https://dev.to/iliaa/the-cost-of-failing-closed-what-shipped-across-nine-php-extensions-5a9l</link>
      <guid>https://dev.to/iliaa/the-cost-of-failing-closed-what-shipped-across-nine-php-extensions-5a9l</guid>
      <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 href="https://ilia.ws/blog/failing-closed-what-shipped-across-seven-php-extensions" rel="noopener noreferrer"&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;h2&gt;
  
  
  php_excel 2.6.0: every save stages and renames
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 2.4.0: stored the string as text. 2.5.0: rejects, and names the cell it stopped at.&lt;/span&gt;
&lt;span class="nv"&gt;$sheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'not a date'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ExcelFormat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AS_DATE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/php_excel" rel="noopener noreferrer"&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  pdo_duckdb 0.5.0: the sandbox stopped charging by the row
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DECIMAL(2,2): DuckDB renders .05, the driver rendered 0.05&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/pdo_duckdb" rel="noopener noreferrer"&gt;github.com/iliaal/pdo_duckdb&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fast_uuid 0.6.0: a batch that reads the clock once
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uuid_v7_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ~8 ms, all carrying the batch-start millisecond&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 "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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/iliaal/fast_uuid" rel="noopener noreferrer"&gt;github.com/iliaal/fast_uuid&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fastchart 1.7.0: limits, and one honest regression
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;fastchart.max_render_pixels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;64M      ; PHP_INI_SYSTEM, bounds raster buffers&lt;/span&gt;
&lt;span class="py"&gt;fastchart.max_image_cache_bytes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;64M  ; PHP_INI_SYSTEM, bounds retained decoded images&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/fastchart" rel="noopener noreferrer"&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fastjson 0.7.0: ext/json parity, down to the error state
&lt;/h2&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;"\/\xFFe"&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fastjson_merge_patch()&lt;/code&gt;, 2,000-key merge&lt;/td&gt;
&lt;td&gt;~95% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large clean-ASCII encode, 256 KiB to 1 MiB&lt;/td&gt;
&lt;td&gt;32 to 66% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tolerant decode on clean input&lt;/td&gt;
&lt;td&gt;20 to 64% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packed-array decode&lt;/td&gt;
&lt;td&gt;~22% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fastjson_pointer_set()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42 to 66% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top-level scalar encode&lt;/td&gt;
&lt;td&gt;35 to 43% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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 href="https://github.com/iliaal/fastjson" rel="noopener noreferrer"&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  phpser 0.5.0: a signature is not a provenance proof
&lt;/h2&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 href="https://github.com/iliaal/phpser" rel="noopener noreferrer"&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  php_clickhouse 0.11.0: values that came back different
&lt;/h2&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;"none"&lt;/code&gt;/&lt;code&gt;"lz4"&lt;/code&gt;/&lt;code&gt;"zstd"&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;"false"&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT m FROM t'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nc"&gt;ClickHouse&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MAP_AS_PAIRS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// ordered [key, value] pairs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/php_clickhouse" rel="noopener noreferrer"&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  mdparser 0.5.0: less memory, same output
&lt;/h2&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="language-php"&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 href="https://github.com/iliaal/mdparser" rel="noopener noreferrer"&gt;github.com/iliaal/mdparser&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  phonetic 0.4.0: constants that cannot be confused
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bmpm()&lt;/code&gt; takes a name type and an accuracy, in that order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;bmpm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$name_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;BMPM_GENERIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$accuracy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;BMPM_APPROX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$language&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 = "any"&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 href="https://github.com/iliaal/phonetic" rel="noopener noreferrer"&gt;github.com/iliaal/phonetic&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a stabilization release actually costs
&lt;/h2&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>php</category>
      <category>performance</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Malcolm X Is Not a Suffix</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Sun, 12 Jul 2026 14:09:16 +0000</pubDate>
      <link>https://dev.to/iliaa/malcolm-x-is-not-a-suffix-4opp</link>
      <guid>https://dev.to/iliaa/malcolm-x-is-not-a-suffix-4opp</guid>
      <description>&lt;p&gt;Parse &lt;code&gt;Malcolm X&lt;/code&gt; with almost any name splitter and you get a suffix. &lt;code&gt;X&lt;/code&gt; looks like a roman numeral, roman numerals are suffixes (&lt;code&gt;John III&lt;/code&gt;, &lt;code&gt;Henry VIII&lt;/code&gt;), so &lt;code&gt;X&lt;/code&gt; becomes the suffix and the surname disappears. That's the kind of bug you only find once you already have a working parser, because it isn't a missing rule. It's two correct rules wanting the same token.&lt;/p&gt;

&lt;p&gt;Last month I wrote about the &lt;a href="https://ilia.ws/blog/casing-aware-php-name-parser" rel="noopener noreferrer"&gt;casing fix in nameparser&lt;/a&gt;: the idea that a parser reading &lt;code&gt;Jane Doe DDS&lt;/code&gt; should keep &lt;code&gt;Doe&lt;/code&gt; as the surname and &lt;code&gt;DDS&lt;/code&gt; as a credential, using letter casing as the signal that lowercasing throws away. Version 1.3.0 is the follow-up, and it is almost entirely about collisions like &lt;code&gt;Malcolm X&lt;/code&gt;: tokens that two disambiguation rules both claim. nameparser is a maintained fork of the dormant &lt;code&gt;theiconic/name-parser&lt;/code&gt;, by way of &lt;code&gt;codebyzach/name-parser&lt;/code&gt;'s PHP 8.3+ modernization; the contribution here is the casing and credential layer plus a confidence pass, not the parser core.&lt;/p&gt;

&lt;h2&gt;
  
  
  The credential that is also a name
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;JO ANDERSON PhD&lt;/code&gt; used to parse to first name &lt;code&gt;J&lt;/code&gt;. &lt;code&gt;Jo&lt;/code&gt; is a real short given name, but in all caps it also reads as a credential-run token, and the credential rule won. 1.3.0 keeps all-caps short given names intact beside a mixed-case salutation or credential, so &lt;code&gt;Jo&lt;/code&gt; survives.&lt;/p&gt;

&lt;p&gt;The mirror image is the credential that gets read as a surname. &lt;code&gt;Jane DDS&lt;/code&gt; (a credential-only row with no comma) and &lt;code&gt;John A. MD&lt;/code&gt; (an initial plus a credential) used to rewrite the trailing credential as the last name, because a token in the surname slot with nothing after it looks like a surname. Both now keep the credential in the suffix.&lt;/p&gt;

&lt;p&gt;The collision gets worse with more than one credential. &lt;code&gt;John Smith MD FACS&lt;/code&gt; used to keep &lt;code&gt;MD&lt;/code&gt; and leak &lt;code&gt;FACS&lt;/code&gt; into the name, because &lt;code&gt;FACS&lt;/code&gt; is not in the dictionary and an unknown trailing token defaults to a name. Now a known credential anchors the tail and pulls the unknown one in with it, so both stay in the suffix. Parenthetical credentials were their own trap: &lt;code&gt;Jane Doe (MD)&lt;/code&gt; used to parse &lt;code&gt;(MD)&lt;/code&gt; as a nickname, since parentheses mark nicknames, and now reads it as a suffix. And a credential-only segment that lands before the given name, as in &lt;code&gt;Smith, MD, John&lt;/code&gt;, is pulled out to the suffix so the first name stays &lt;code&gt;John&lt;/code&gt; rather than becoming &lt;code&gt;MD&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Casing is the tiebreaker for all of these, which means it fails exactly where casing carries no signal. Uniform all-caps registry data, or all-lowercase input, has nothing to disambiguate on, so an ambiguous trailing token defaults to being read as a credential. That's a hard limitation, not a bug, and it's why the library ships a confidence pass: &lt;code&gt;getConfidence()&lt;/code&gt; flags a casing-undecidable row as ambiguous so it can go to a human instead of silently into your database wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Word order that hides the surname
&lt;/h2&gt;

&lt;p&gt;Surname-first input is its own minefield when a salutation and a credential sit on the same name. &lt;code&gt;Dr. Kim Jong Un&lt;/code&gt; used to lose the surname to the title; &lt;code&gt;Kim Jong Un, MD&lt;/code&gt; used to fall back to Western order. Both now keep surname &lt;code&gt;Kim&lt;/code&gt;, and &lt;code&gt;Dr. Kim Jong Un, MD&lt;/code&gt; correctly yields surname &lt;code&gt;Kim&lt;/code&gt;, salutation &lt;code&gt;Dr.&lt;/code&gt;, and credential &lt;code&gt;MD&lt;/code&gt;. The salutation and the credential were each pulling the parser back toward a different word order, and the fix is teaching it that a leading salutation and a trailing credential-only comma tail can coexist with a surname-first name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comma inside the nickname
&lt;/h2&gt;

&lt;p&gt;A comma in a name usually means surname-first. So a comma inside a delimited nickname is a direct collision with the rule that reads commas as name-order markers. &lt;code&gt;John (Bob, Jr) Doe&lt;/code&gt; and &lt;code&gt;John 'Bob, Jr' Doe&lt;/code&gt; used to bisect on that inner comma and lose half the name. 1.3.0 keeps the nickname &lt;code&gt;Bob, Jr&lt;/code&gt; whole, for bracketed, quoted, and custom multi-character delimiter pairs alike, and the same holds on the given-name side: &lt;code&gt;Smith, John (Jack, Robert)&lt;/code&gt; keeps &lt;code&gt;Jack, Robert&lt;/code&gt; intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scripts with no casing at all
&lt;/h2&gt;

&lt;p&gt;If casing is the signal, a script without upper and lower case has none. &lt;code&gt;Wang, 李明&lt;/code&gt; used to split &lt;code&gt;李明&lt;/code&gt; into two initials, because the initials rule fires on short tokens and nothing told it these were not initials. 1.3.0 leaves caseless-script names intact as the first name. In the other direction, an elided surname particle like the one in &lt;code&gt;'t Hooft&lt;/code&gt; used to read as an unterminated nickname or an initial; it now survives as the leading particle. Dutch honorifics (&lt;code&gt;Dhr.&lt;/code&gt;, &lt;code&gt;Mevr.&lt;/code&gt;, &lt;code&gt;Mw.&lt;/code&gt;) also join the default salutation set, so &lt;code&gt;Dhr. Jan de Vries&lt;/code&gt; reads the title as a salutation rather than a first name.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the input that is trying to break you
&lt;/h2&gt;

&lt;p&gt;A name parser sits directly on user-supplied strings, so an adversarial input is a real exposure, not a theoretical one. Kilobytes of unmatched quotes, 100 KB tokens of any case shape, megabyte-long rows: 1.3.0 parses all of them in linear time and bounded memory. Invalid-UTF-8 delimiter keys and empty whitespace sets that used to emit a compile warning on every parse are now ignored quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a wrong split into a review queue
&lt;/h2&gt;

&lt;p&gt;Every collision above resolves on casing, and casing runs out of signal on uniform-case data. So the honest design isn't to pretend the parser is always right; it's to say when it's guessing. The confidence pass reads the same input the parser does and flags a row whose split hinges on a signal that is not there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Iliaal\NameParser\Parser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Parser&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SMITH, JM'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConfidence&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// ['ambiguous' =&amp;gt; true, 'notes' =&amp;gt; [...]]&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// 'SMITH, JM'  (the normalized input it judged)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SMITH, JM&lt;/code&gt; is exactly the case with no answer: with mixed case, &lt;code&gt;Smith, JM&lt;/code&gt; splits &lt;code&gt;JM&lt;/code&gt; into the initials &lt;code&gt;J&lt;/code&gt; and &lt;code&gt;M&lt;/code&gt;, because the mixed-case surname proves those are initials; all-caps &lt;code&gt;SMITH, JM&lt;/code&gt; keeps &lt;code&gt;Jm&lt;/code&gt; as a first name, because the caps could equally be a short given name. The parser has to pick one, and the confidence flag records that it had to. &lt;code&gt;getSource()&lt;/code&gt;, new in 1.3.0, returns the normalized string the confidence was assessed against, so a batch importer can log precisely what it judged. The flag is advisory and opt-in; it never changes what &lt;code&gt;parse()&lt;/code&gt; returns. On a million-row import, that is the difference between silently corrupting an unknown fraction of records and routing a known set of them to a person.&lt;/p&gt;

&lt;p&gt;One caution the README states and this post repeats: &lt;code&gt;ambiguous =&amp;gt; false&lt;/code&gt; on an all-caps row is not a correctness guarantee. Clean credentials that are not also names (&lt;code&gt;RN&lt;/code&gt;, &lt;code&gt;PT&lt;/code&gt;, &lt;code&gt;OD&lt;/code&gt;) are left unflagged on purpose, to keep review volume manageable, so a false there means "no name-colliding token," not "definitely right."&lt;/p&gt;

&lt;h2&gt;
  
  
  The seams where cases meet
&lt;/h2&gt;

&lt;p&gt;None of these are new features. They're the boundaries between rules that were already there, the places where two disambiguation heuristics both reach for the same token. That's what this round of work looks like: not more cases, but the seams where cases meet. Casing decides most of them. Where it cannot, the confidence flag says so, and the row goes to review instead of into your data wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require iliaal/nameparser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/iliaal/nameparser" rel="noopener noreferrer"&gt;github.com/iliaal/nameparser&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Failing Closed: What Shipped Across Seven PHP Extensions</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Fri, 10 Jul 2026 13:24:45 +0000</pubDate>
      <link>https://dev.to/iliaa/failing-closed-what-shipped-across-seven-php-extensions-4gkl</link>
      <guid>https://dev.to/iliaa/failing-closed-what-shipped-across-seven-php-extensions-4gkl</guid>
      <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 href="https://ilia.ws/blog/lowering-the-php-floor-what-shipped-across-five-extensions" rel="noopener noreferrer"&gt;June one&lt;/a&gt; covered the version-floor drops. Here is what landed since.&lt;/p&gt;

&lt;h2&gt;
  
  
  mdparser 0.4.3: a new engine under the same API
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;MdParser\&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nc"&gt;Parser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Parser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latexMath&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wikiLinks&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$source&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="..."&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 href="https://github.com/iliaal/mdparser" rel="noopener noreferrer"&gt;github.com/iliaal/mdparser&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fastchart 1.6.0: from 26 chart types to 38
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FastChart\Dendrogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$chart&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setHierarchy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$chart&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;renderToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tree.svg'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/fastchart" rel="noopener noreferrer"&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  php_clickhouse 0.10.0: JSON, Bool, and IPv6 columns, and a faster decoder
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FixedString payloads (IPv6, hash digests) read back at full width, trailing NULs kept&lt;/span&gt;
&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT ip FROM sessions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nc"&gt;ClickHouse&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;FIXEDSTRING_BINARY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/php_clickhouse" rel="noopener noreferrer"&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  phpser 0.4.0: a columnar wire format for rowsets
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;phpser_serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// columnar automatically when rows share a schema&lt;/span&gt;
&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;phpser_unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/phpser" rel="noopener noreferrer"&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fastjson 0.6.0: JSON Pointer you can write to, and prebuilt binaries
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fastjson_pointer_set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/user/email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new@example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/fastjson" rel="noopener noreferrer"&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  php_excel 2.4.0: bulk reads and a hardening pass
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$sheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;readRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// a 1000 x 26 region in one call&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/php_excel" rel="noopener noreferrer"&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fast_uuid 0.4.0: binary and batch generators, and ramsey/uuid parity
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;uuid_v7_bin_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 1000 raw 16-byte v7 UUIDs, monotonic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/fast_uuid" rel="noopener noreferrer"&gt;github.com/iliaal/fast_uuid&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous majority
&lt;/h2&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>php</category>
      <category>performance</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I objected to deprecating metaphone(). Then I read the RFC.</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Wed, 01 Jul 2026 13:52:15 +0000</pubDate>
      <link>https://dev.to/iliaa/i-objected-to-deprecating-metaphone-then-i-read-the-rfc-4ai5</link>
      <guid>https://dev.to/iliaa/i-objected-to-deprecating-metaphone-then-i-read-the-rfc-4ai5</guid>
      <description>&lt;p&gt;A while back a proposal landed on the PHP internals list: &lt;a href="https://discourse.thephp.foundation/t/php-dev-rfc-deprecate-metaphone-function/5391" rel="noopener noreferrer"&gt;deprecate &lt;code&gt;metaphone()&lt;/code&gt;&lt;/a&gt;. My first reaction was the reflex of someone who has spent years as a PHP release master. Leave the old string functions alone, people depend on them, deprecation churn is its own tax. I was ready to argue against it.&lt;/p&gt;

&lt;p&gt;Then I read the reasoning and went looking at what phonetic name matching is actually supposed to do in 2026. I changed my mind. &lt;code&gt;metaphone()&lt;/code&gt; should go. What surprised me was not that the function is dated. It was how far the field had moved while PHP core stood still, and how weak the replacement path the RFC points at really is. So I built the replacement I wish it had recommended.&lt;/p&gt;

&lt;h2&gt;
  
  
  metaphone() is the oldest, least accurate version of an idea that kept evolving
&lt;/h2&gt;

&lt;p&gt;The RFC is right, and here is the short version of why. The &lt;code&gt;metaphone()&lt;/code&gt; in core is the original 1990 algorithm: English-only, single-key, tuned for one accent of one language. It was superseded twice. First by Double Metaphone (Lawrence Philips, 2000), which emits a primary and an alternate key so ambiguous pronunciations still match. Then by Metaphone 3, which corrects hundreds more edge cases.&lt;/p&gt;

&lt;p&gt;Core shipped the first version and never moved. &lt;code&gt;soundex()&lt;/code&gt; is older still, a 1918 patent. Both encode a single English-centric key, and both fall over the moment a name crosses a language boundary or carries a transliteration variant. For the one job these functions exist to do, collapsing names that sound alike but are spelled differently, they are the weakest tools in the drawer.&lt;/p&gt;

&lt;p&gt;So the deprecation is defensible. Maintaining the least accurate member of a whole family of algorithms, in C, in core, when most applications can do better elsewhere, is not a good use of anyone's time. I came around to that part.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Use a userland library" is the wrong replacement
&lt;/h2&gt;

&lt;p&gt;Here is where I part ways with the RFC. Its answer to "what do I use instead" is that there are actively maintained Composer libraries implementing Double Metaphone. That is true, and it is also the wrong instinct.&lt;/p&gt;

&lt;p&gt;Phonetic encoding is a hot inner-loop operation. You run it over every name in a dataset, sometimes millions of them, to build a match index you can query later. A pure-PHP implementation pays interpreter overhead on every character of every name. The honest replacement for a native C string function is another native C string function, not a userland reimplementation that is correct but an order of magnitude slower. That performance gap is the entire reason the code lived in core to begin with.&lt;/p&gt;

&lt;p&gt;That is what moved me from "argue against the RFC" to "agree with the RFC, then go build the thing it should have recommended." The result is &lt;a href="https://github.com/iliaal/phonetic" rel="noopener noreferrer"&gt;&lt;code&gt;phonetic&lt;/code&gt;&lt;/a&gt;, a native extension that ships the five encoders core never had, plus the comparison helpers that answer the only question most people actually ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  Double Metaphone: the successor you can actually ship
&lt;/h2&gt;

&lt;p&gt;Double Metaphone is the algorithm the RFC's own rationale reaches for first, and it is the natural default. It returns two keys, a primary and an alternate, so a name with more than one plausible pronunciation matches on either.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;double_metaphone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Schwarzenegger"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ['primary' =&amp;gt; 'XRSN', 'alternate' =&amp;gt; 'XFRT']&lt;/span&gt;
&lt;span class="nf"&gt;double_metaphone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Catherine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// ['primary' =&amp;gt; 'K0R',  'alternate' =&amp;gt; 'KTR']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RFC names Metaphone 3 as the other successor. Worth knowing before you reach for it: the Metaphone 3 reference implementation is &lt;a href="https://www.amorphics.com/buy_metaphone3.html" rel="noopener noreferrer"&gt;a commercial product&lt;/a&gt;, sold as source for $240, not free software. An older 2009 build sits under a BSD license inside OpenRefine, minus years of accuracy corrections. Double Metaphone is the newest variant you can actually vendor into an open project, which is why it, and not Metaphone 3, is what belongs within reach of every PHP app. My implementation is clean-room from the published algorithm, with &lt;a href="https://commons.apache.org/proper/commons-codec/" rel="noopener noreferrer"&gt;Apache Commons Codec&lt;/a&gt; used only as a parity oracle for the test vectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beider-Morse: matching across languages, and the GPL trap I had to dodge
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Moskowitz&lt;/code&gt; and &lt;code&gt;Moskovitz&lt;/code&gt; are one surname through two transliterations. &lt;code&gt;Иванов&lt;/code&gt; is a name most encoders will not touch. Beider-Morse Phonetic Matching handles both, because it is language-aware. It detects, or is told, the source language family, then applies the right transliteration rules for Slavic, Germanic, Hebrew, and Romance names.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;bmpm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Garcia"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;BMPM_SEPHARDIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;BMPM_EXACT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "garsia|gartSa"&lt;/span&gt;
&lt;span class="nf"&gt;bmpm_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Moskowitz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Moskovitz"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard part is not the code, it is the data. BMPM is thousands of rules, and every obvious source for those rules is GPL. The canonical Beider-Morse PHP reference is GPL-3.0. abydos, the popular Python phonetic library, is GPL-3.0 specifically because it ported that same rule data. Copy either into a BSD project and the whole project turns GPL with it.&lt;/p&gt;

&lt;p&gt;The escape is Apache Commons Codec, which ships the identical rule tables under Apache-2.0. I vendored the data from there, kept its license header, and added an Apache-2.0 section to the extension's LICENSE. Same data, clean license, and Commons Codec doubles as the parity oracle, so I know the output matches the de-facto reference. The lesson generalizes past this one extension: with phonetic algorithms, the &lt;em&gt;data&lt;/em&gt; carries the license, not the code you wrap around it.&lt;/p&gt;

&lt;p&gt;One honest caveat. BMPM is slow. It runs language detection and three rule passes over the input, so it costs roughly 60 times a Double Metaphone call, around 91,000 names a second on my machine. You pick it for recall, not throughput. When you already know the language, passing it explicitly skips detection and buys some of that back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Daitch-Mokotoff Soundex: the genealogy standard
&lt;/h2&gt;

&lt;p&gt;If you are matching Eastern-European or Ashkenazi surnames, this is the field standard, and core never had it. Daitch-Mokotoff was built for exactly that problem, and it is what genealogy databases actually run. It emits a set of six-digit codes, branching on ambiguous letters so one name can carry several codes at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;dm_soundex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Auerbach"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                     &lt;span class="c1"&gt;// ['097400', '097500']&lt;/span&gt;
&lt;span class="nf"&gt;dm_soundex_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Moskowitz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Moskovitz"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its rule data comes from Apache Commons Codec too, for the same licensing reason as BMPM.&lt;/p&gt;

&lt;h2&gt;
  
  
  NYSIIS and Match Rating: two lighter English encoders
&lt;/h2&gt;

&lt;p&gt;Both are cheap single-key encoders for American and English names, useful as a second opinion or an alternate index key. NYSIIS (New York State Identification and Intelligence System) is tuned for American surnames and returns one key. Match Rating Approach (Western Airlines, 1977) produces a compact codex and, unusually, ships its own similarity test instead of relying on key equality.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;nysiis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Larson"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// "LARSAN"&lt;/span&gt;
&lt;span class="nf"&gt;nysiis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Larsen"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// "LARSAN"  (same key)&lt;/span&gt;
&lt;span class="nf"&gt;match_rating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Catherine"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "CTHRN"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are clean-room from their published specs. They are the fastest encoders in the set, cheap enough to run as a second key alongside Double Metaphone when you want a little more recall without paying BMPM's cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I use most: the comparison helpers
&lt;/h2&gt;

&lt;p&gt;The real day-to-day question is never "encode this name." It is "do these two names sound alike," and every algorithm answers it differently. That difference is where userland code quietly gets phonetic matching wrong.&lt;/p&gt;

&lt;p&gt;Double Metaphone gives two keys; you match on primary agreement, weaker if only an alternate crosses. Daitch-Mokotoff and BMPM give sets; you match on intersection, not equality. Match Rating has a length-and-rating threshold that plain codex comparison skips entirely. Get that logic wrong and you either miss real matches or wave through garbage. So the extension ships one comparison helper per algorithm, each encoding the correct test for that encoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;double_metaphone_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Catherine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Kathryn"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 2  (primary keys agree)&lt;/span&gt;
&lt;span class="nf"&gt;double_metaphone_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Vagner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Wagner"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 1  (only an alternate crosses)&lt;/span&gt;
&lt;span class="nf"&gt;dm_soundex_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Moskowitz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Moskovitz"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nf"&gt;bmpm_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Peterson"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Petersen"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nf"&gt;nysiis_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Smith"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Schmit"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                   &lt;span class="c1"&gt;// true  (both SNAT)&lt;/span&gt;
&lt;span class="nf"&gt;match_rating_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Catherine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Kathryn"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;double_metaphone_match()&lt;/code&gt; returns 2, 1, or 0, so you can rank match strength instead of treating it as a coin flip. The set-based helpers return a bool on intersection. &lt;code&gt;match_rating_compare()&lt;/code&gt; applies the threshold the algorithm actually specifies. You call one function and get the right answer for that encoder, rather than reimplementing set intersection in a loop and getting the edge cases subtly wrong.&lt;/p&gt;

&lt;p&gt;For a one-off check, that is the whole API. For repeated lookups against a fixed corpus, encode once and index the keys, then query by encoded value instead of comparing pair by pair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$records&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dm_soundex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// index every code in the set&lt;/span&gt;
        &lt;span class="nv"&gt;$index&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$index&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;dm_soundex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Moskovitz"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which one do I reach for?
&lt;/h2&gt;

&lt;p&gt;Double Metaphone as the fast general-purpose default. BMPM when names cross languages or scripts and you can afford the cost. Daitch-Mokotoff for Eastern-European and Jewish genealogy, where it is the standard. NYSIIS or Match Rating as a cheap second key. The relative costs matter when you are encoding at scale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;encoder&lt;/th&gt;
&lt;th&gt;relative speed&lt;/th&gt;
&lt;th&gt;strongest for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;match_rating()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fastest (0.24x)&lt;/td&gt;
&lt;td&gt;English names; ships its own similarity test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nysiis()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fast (0.42x)&lt;/td&gt;
&lt;td&gt;American and English surnames&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;double_metaphone()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fast (1.0x baseline)&lt;/td&gt;
&lt;td&gt;general Latin-script names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dm_soundex()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;middle (~2.3x)&lt;/td&gt;
&lt;td&gt;Eastern-European and Ashkenazi surnames&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bmpm()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;slowest (~60x)&lt;/td&gt;
&lt;td&gt;cross-language and transliteration variants&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two limits worth stating plainly. These are heuristic, culture-bound encoders. They target Latin-script names, and for BMPM and Daitch-Mokotoff, specific language families. They are not a universal global-name solution. And Greek-script input has a known limitation: capitals are not lowercased, because the context-sensitive final-sigma rule cannot be expressed as a point-wise case map, so Greek names need to be passed lowercased or romanized.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I landed
&lt;/h2&gt;

&lt;p&gt;I set out to defend &lt;code&gt;metaphone()&lt;/code&gt; and finished by agreeing it should be deprecated. The function is the weakest version of an idea that produced far better tools over the thirty years since. The flaw in the deprecation story is not the deprecation. It is pointing at a slower userland library as the replacement for a native one. Phonetic encoding runs in a hot loop, so it belongs in C.&lt;/p&gt;

&lt;p&gt;The five encoders core never shipped are now one native extension, with the "do these sound alike" helpers most callers actually need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pie &lt;span class="nb"&gt;install &lt;/span&gt;iliaal/phonetic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/iliaal/phonetic" rel="noopener noreferrer"&gt;github.com/iliaal/phonetic&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>phonetic</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Forked a Dead PHP Name Parser Because It Couldn't Tell a Credential From a Surname</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:32:57 +0000</pubDate>
      <link>https://dev.to/iliaa/i-forked-a-dead-php-name-parser-because-it-couldnt-tell-a-credential-from-a-surname-316o</link>
      <guid>https://dev.to/iliaa/i-forked-a-dead-php-name-parser-because-it-couldnt-tell-a-credential-from-a-surname-316o</guid>
      <description>&lt;p&gt;Here is a bug in a name-parsing library I use at work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// theiconic/name-parser&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Jane Doe DDS'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getLastname&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// "Dds"&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMiddlename&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// "Doe"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dental credential is now her last name. The real surname got shoved into the middle-name field. Every row with a trailing credential and no comma had some version of this, and in a list of clinicians that is most of them.&lt;/p&gt;

&lt;p&gt;The library is &lt;a href="https://github.com/theiconic/name-parser" rel="noopener noreferrer"&gt;theiconic/name-parser&lt;/a&gt;, a small, genuinely useful PHP package that splits a full-name string into salutation, first name, initials, last name, suffix, and nickname. I use it at work. It does the boring parts well. But the upstream repo went quiet around 2020, and bugs like the one above never got fixed.&lt;/p&gt;

&lt;p&gt;So I forked it. Today I'm releasing &lt;a href="https://github.com/iliaal/nameparser" rel="noopener noreferrer"&gt;iliaal/nameparser&lt;/a&gt;, a maintained fork that fixes the credential handling, adds a confidence signal for the cases it genuinely can't decide, and targets PHP 8.3+. Most of the fix is unglamorous boundary work. One part of it rests on a single idea worth writing down: the parser was throwing away the one signal that tells a credential from a name.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Credential Is Also a Name
&lt;/h2&gt;

&lt;p&gt;There are two ways a credential ends up in the wrong field, and they are not the same bug.&lt;/p&gt;

&lt;p&gt;The easy one is &lt;code&gt;Jane Doe DDS&lt;/code&gt;. &lt;code&gt;DDS&lt;/code&gt; is never a name, so the only question is where the token boundary sits. Upstream, with no comma to anchor it, takes the last token as the surname and never asks whether it is actually a trailing credential. The fork checks, recognizes &lt;code&gt;DDS&lt;/code&gt; as a suffix, and keeps &lt;code&gt;Doe&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// fork&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Jane Doe DDS'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getLastname&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// "Doe"&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSuffix&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// "DDS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard one is when the token is both a name and a credential. &lt;code&gt;Ma&lt;/code&gt; is a surname. &lt;code&gt;MA&lt;/code&gt; is a master's degree. &lt;code&gt;Do&lt;/code&gt; is a Vietnamese surname and a doctor of osteopathic medicine. Feed upstream a comma form where the given name is one of these and the name disappears:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// theiconic/name-parser&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Smith, Ma'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFirstname&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// ""&lt;/span&gt;
&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSuffix&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// "MA"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The given name &lt;code&gt;Ma&lt;/code&gt; was stripped into the suffix as the credential MA. The field is now empty. Nothing threw, nothing warned; a person's first name was deleted because it collided with a degree abbreviation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Casing Is the Signal Your Parser Throws Away
&lt;/h2&gt;

&lt;p&gt;Upstream keys every token through &lt;code&gt;strtolower()&lt;/code&gt; before matching it against its credential dictionary. Once &lt;code&gt;Ma&lt;/code&gt; becomes &lt;code&gt;ma&lt;/code&gt;, it matches MA and gets stripped. The piece of information that would have saved it, the capital pattern, was deleted on the first line.&lt;/p&gt;

&lt;p&gt;People write credentials in capitals and names in title case. &lt;code&gt;Smith, Ma&lt;/code&gt; is a person named Ma. &lt;code&gt;Smith, MA&lt;/code&gt; is someone with a master's degree and no recorded first name. The capitalization is not decoration; it is the writer's own distinction between the two, and lowercasing throws it away before anything looks at it.&lt;/p&gt;

&lt;p&gt;The fork stops throwing it away. An ambiguous token, one that collides with both a name and a credential, is treated as a credential only when it is written in all caps. Title case or lower case keeps it as a name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// fork&lt;/span&gt;
&lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Smith, Ma'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFirstname&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// "Ma"   title case, kept as a name&lt;/span&gt;
&lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Smith, MA'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFirstname&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// ""     all caps, read as the credential MA&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same idea fixes a second class of mangling. Under all-caps input there is no case left to mark a two-letter token as a set of initials, so upstream guesses wrong and splits a short given name down to one letter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'JO ANDERSON'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFirstname&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// upstream: "J"   (JO read as the initial J)&lt;/span&gt;
&lt;span class="c1"&gt;// fork:     "Jo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same dictionary, same tokens. The difference is that casing now decides the ambiguous cases instead of being discarded before the decision is made.&lt;/p&gt;

&lt;p&gt;This is a small change with a large blast radius, because the failure was silent. Upstream didn't throw. It wrote a wrong field and moved on, which is exactly the kind of bug you find six weeks later in a report nobody can reconcile.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Casing Can't Decide: A Confidence Signal
&lt;/h2&gt;

&lt;p&gt;Casing only works when there is casing to read. Uniform-case input, an all-caps legacy export or an all-lowercase dump, carries no signal at all. &lt;code&gt;NGUYEN, VI&lt;/code&gt; could be the surname Nguyen with the given name Vi, or the surname Nguyen with the credential VI, and nothing in the string tells you which. The parser has to pick a default, and a default is a guess.&lt;/p&gt;

&lt;p&gt;For a one-off parse, a guess is fine. For a batch import of a few hundred thousand person records, a silent guess is a data-integrity problem you won't notice until it matters. So the fork adds an advisory pass that tells you when the input was undecidable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Iliaal\NameParser\Confidence&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Confidence&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'NGUYEN, VI'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// [&lt;/span&gt;
&lt;span class="c1"&gt;//   'ambiguous' =&amp;gt; true,&lt;/span&gt;
&lt;span class="c1"&gt;//   'notes' =&amp;gt; ["'VI' could be a name or a credential; input casing is uniform"],&lt;/span&gt;
&lt;span class="c1"&gt;// ]&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ambiguous'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// route this row to manual review instead of trusting the split&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same signal is available on the parsed result, derived from the same input the parser saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'NGUYEN, VI'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConfidence&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// ['ambiguous' =&amp;gt; true, 'notes' =&amp;gt; [...]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getConfidence()&lt;/code&gt; is read-only. It doesn't change what &lt;code&gt;parse()&lt;/code&gt; returns; it's a second opinion you opt into. A mixed-case &lt;code&gt;Nguyen, Vi&lt;/code&gt; stays unflagged, because the title-case &lt;code&gt;Vi&lt;/code&gt; already resolved to a given name. The flag fires only when the casing genuinely could not decide.&lt;/p&gt;

&lt;p&gt;Here is the limitation, stated plainly, because the post that hides it is the post you stop trusting: this is a heuristic keyed on casing, and uniform-case data has no casing to key on. On an all-caps dataset, an ambiguous trailing token still reads as a credential by default. What the confidence pass buys you is a queue. It flags the uniform-case rows where the token plausibly collides with a real name, so you can review those instead of trusting all of them. It does not flag clean credentials that are not also names, RN, PT, OD, because flagging every one of those would drown the review queue on exactly the all-caps data where review matters most. An &lt;code&gt;ambiguous =&amp;gt; false&lt;/code&gt; on all-caps input is not a correctness guarantee. It means no name-collision was detected, not that the split is definitely right.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Else Changed
&lt;/h2&gt;

&lt;p&gt;The casing work is the headline, but a maintained fork is also a place to fix the smaller things that accumulate in a dormant library.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;toArray()&lt;/code&gt; method returns every part under a fixed key set, with an empty string for any part that is absent. Upstream's &lt;code&gt;getAll()&lt;/code&gt; omits empty parts and varies its keys, so consuming it means existence-checking every field. &lt;code&gt;toArray()&lt;/code&gt; is a stable shape you can hand to &lt;code&gt;json_encode()&lt;/code&gt; or a DTO without guards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$parser&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Dr. Jane A. Doe DDS'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// [&lt;/span&gt;
&lt;span class="c1"&gt;//   'salutation' =&amp;gt; 'Dr.', 'firstname' =&amp;gt; 'Jane', 'initials' =&amp;gt; 'A.',&lt;/span&gt;
&lt;span class="c1"&gt;//   'middlename' =&amp;gt; '', 'lastname_prefix' =&amp;gt; '', 'lastname' =&amp;gt; 'Doe',&lt;/span&gt;
&lt;span class="c1"&gt;//   'suffix' =&amp;gt; 'DDS', 'nickname' =&amp;gt; '', 'given_name' =&amp;gt; 'Jane A.',&lt;/span&gt;
&lt;span class="c1"&gt;//   'full_name' =&amp;gt; 'Jane A. Doe',&lt;/span&gt;
&lt;span class="c1"&gt;// ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The credential dictionary grew teeth for healthcare data specifically. Beyond the standard academic and professional suffixes, the fork adds nursing and allied-health credentials, RN, NP, PharmD, APRN, PA-C, OTR/L, and thirty-odd more, mined by frequency from the NPI registry. If you parse clinician names, a trailing credential no longer leaks into the first name.&lt;/p&gt;

&lt;p&gt;A handful of robustness fixes round it out. An unclosed nickname delimiter no longer swallows the surname: upstream parses &lt;code&gt;John (Bob Smith&lt;/code&gt; to a last name of &lt;code&gt;John&lt;/code&gt;, the fork keeps &lt;code&gt;Smith&lt;/code&gt;. A lone bracket or quote token returns an empty &lt;code&gt;Name&lt;/code&gt; instead of crashing &lt;code&gt;parse()&lt;/code&gt; with a &lt;code&gt;TypeError&lt;/code&gt;. Config setters take effect on a reused parser even when called after the first &lt;code&gt;parse()&lt;/code&gt;. Everything after a second comma is kept as a middle name, so &lt;code&gt;Smith, John, Robert&lt;/code&gt; keeps &lt;code&gt;Robert&lt;/code&gt; while &lt;code&gt;Smith, MD, PhD&lt;/code&gt; still strips to suffixes.&lt;/p&gt;

&lt;p&gt;The fork targets PHP 8.3+ and is tested through 8.5, runs clean under PHPStan level 9, and the full upstream getter surface is unchanged. It's additive. If you were on the original, the methods you called still return what they returned, minus the credential bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credit Where It's Due
&lt;/h2&gt;

&lt;p&gt;This is a fork, not original work, and the lineage matters. The parser core is &lt;a href="https://github.com/theiconic" rel="noopener noreferrer"&gt;The Iconic&lt;/a&gt;'s. The modernization to PHP 8.3+ that I built on came from &lt;a href="https://github.com/CodeByZach" rel="noopener noreferrer"&gt;Zachary Miller&lt;/a&gt;'s fork. What I added is the casing-and-credential layer, the confidence signal, and the robustness fixes above. I'm standing on two other people's work and I'd rather say so than pretend the whole thing sprang from nothing.&lt;/p&gt;

&lt;p&gt;Install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require iliaal/nameparser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo, with the full changelog and the all-caps limitation documented in the README, is at &lt;a href="https://github.com/iliaal/nameparser" rel="noopener noreferrer"&gt;github.com/iliaal/nameparser&lt;/a&gt;. If you parse names from professional or registry data and you have ever found a credential sitting in a surname column, this is the fix.&lt;/p&gt;

</description>
      <category>php</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>pdo_duckdb: DuckDB for PHP, Behind the PDO API You Already Know</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Fri, 19 Jun 2026 18:36:37 +0000</pubDate>
      <link>https://dev.to/iliaa/pdoduckdb-duckdb-for-php-behind-the-pdo-api-you-already-know-40k4</link>
      <guid>https://dev.to/iliaa/pdoduckdb-duckdb-for-php-behind-the-pdo-api-you-already-know-40k4</guid>
      <description>&lt;p&gt;&lt;a href="https://duckdb.org/" rel="noopener noreferrer"&gt;DuckDB&lt;/a&gt; is the closest thing the analytics world has to SQLite. It runs in-process, needs no server, reads and writes a single file, and chews through columnar aggregate queries that would make a row-store sweat. PHP has shipped &lt;code&gt;PDO_SQLite&lt;/code&gt; in core for twenty years. Until now it had no equivalent for DuckDB.&lt;/p&gt;

&lt;p&gt;So I wrote one. &lt;code&gt;pdo_duckdb&lt;/code&gt; is a native PDO driver. You open a DuckDB database with a DSN, prepare statements, bind parameters, and iterate results through the same &lt;code&gt;PDO&lt;/code&gt; API you already use for SQLite, MySQL, and PostgreSQL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PDO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'duckdb:/path/to/analytics.duckdb'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ATTR_ERRMODE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;PDO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ERRMODE_EXCEPTION&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT region, SUM(amount) AS total FROM sales WHERE year = ? GROUP BY region'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stmt&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%s: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'region'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also the first PDO driver I've written in over fifteen years. I was one of the original authors of PDO back in the PHP 5.1 days, so coming back to that extension surface was equal parts muscle memory and culture shock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a PDO Driver and Not Another FFI Binding
&lt;/h2&gt;

&lt;p&gt;You can already reach DuckDB from PHP today. It has a C API you can drive through FFI, and there's always shelling out to the CLI. Both work. Both also hand you a brand-new API surface, manual lifetime management, and a data layer that looks nothing like the rest of your application.&lt;/p&gt;

&lt;p&gt;PDO is the abstraction PHP developers already have in their fingers. A PDO driver means prepared statements, parameter binding, the exception error mode, fetch modes, and transactions all behave the way they do for every other PDO backend. Your repository classes and query builders transfer unchanged. The driver is written in C against DuckDB's C API, so there is no FFI boundary at runtime and no per-call marshalling happening up in PHP.&lt;/p&gt;

&lt;p&gt;To be fair to FFI, it has its place. If you need a DuckDB capability the PDO surface doesn't expose, FFI hands you the entire C API and gets out of the way. pdo_duckdb makes the opposite trade on purpose: less breadth, in exchange for fitting into code you've already written.&lt;/p&gt;

&lt;h2&gt;
  
  
  DuckDB in One Paragraph, If You Haven't Met It
&lt;/h2&gt;

&lt;p&gt;DuckDB is an in-process analytical (OLAP) database. The relationship to ClickHouse or Snowflake is roughly what SQLite is to Postgres: same SQL you know, no server to run, embedded directly in your process. It is columnar and vectorized, built for scans and aggregations over a lot of rows rather than single-row OLTP traffic. The DSN tells the driver which database to open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;duckdb:/path/to/database.duckdb   # file-backed database
duckdb::memory:                   # in-memory database
duckdb:                           # in-memory database (empty path)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An in-memory database is scratch space that vanishes when the connection closes; a file-backed one persists to disk. DuckDB's own extensions load through ordinary SQL, with no special driver API to learn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'LOAD json'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                     &lt;span class="c1"&gt;// bundled extensions load offline&lt;/span&gt;
&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INSTALL httpfs; LOAD httpfs;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// downloadable extensions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading Parquet, querying a CSV in place, or pulling a file over HTTP is a &lt;code&gt;LOAD&lt;/code&gt; away, all through the same &lt;code&gt;$db-&amp;gt;exec()&lt;/code&gt; you would use for any statement. The driver doesn't wrap or re-expose any of it. DuckDB's SQL surface is the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bulk Loading: The Appender
&lt;/h2&gt;

&lt;p&gt;Row-by-row &lt;code&gt;INSERT&lt;/code&gt; is the wrong way to push a lot of data into a columnar store, and DuckDB knows it. The driver exposes DuckDB's native appender through &lt;code&gt;PDO::duckdbAppender()&lt;/code&gt;, which streams rows into a table far faster than a loop of prepared &lt;code&gt;INSERT&lt;/code&gt; statements.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CREATE TABLE events (id INTEGER, name VARCHAR, ts TIMESTAMP)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;duckdbAppender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'events'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// optional 2nd arg: schema name&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;appendRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ts'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                              &lt;span class="c1"&gt;// or $app-&amp;gt;close() to finalize&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;appendRow(...$values)&lt;/code&gt; takes one argument per column, left to right, and returns the appender so calls can chain. PHP &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt;, and &lt;code&gt;string&lt;/code&gt; map to DuckDB values and get cast to the target column types. This is the one place the driver steps outside the standard PDO surface, and it earns the exception. Bulk ingestion is exactly where the row-at-a-time PDO model leaves the most performance on the table, so it's the one spot worth a driver-specific method.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Parts of PDO That Don't Map Cleanly
&lt;/h2&gt;

&lt;p&gt;This is the honest section. DuckDB is not MySQL, and a faithful driver has to refuse a few things rather than fake them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lastInsertId()&lt;/code&gt; is not supported. DuckDB has no implicit rowid, so there is nothing honest to return. If you need generated keys, use a sequence and &lt;code&gt;currval()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setAttribute(PDO::ATTR_AUTOCOMMIT, false)&lt;/code&gt; is rejected. DuckDB is autocommit-by-default with no session-level toggle, so instead of pretending otherwise, the driver points you at &lt;code&gt;beginTransaction()&lt;/code&gt; for explicit transactions. From there &lt;code&gt;commit()&lt;/code&gt; and &lt;code&gt;rollBack()&lt;/code&gt; map onto DuckDB's &lt;code&gt;COMMIT&lt;/code&gt; and &lt;code&gt;ROLLBACK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Placeholders get rewritten. Positional &lt;code&gt;?&lt;/code&gt; and named &lt;code&gt;:name&lt;/code&gt; parameters are both supported, and PDO rewrites them to DuckDB's native &lt;code&gt;$N&lt;/code&gt; form. That creates one sharp edge: because &lt;code&gt;:&lt;/code&gt; introduces a named placeholder, an inline &lt;code&gt;STRUCT&lt;/code&gt; or &lt;code&gt;MAP&lt;/code&gt; literal inside a prepared query needs a space after the colon. Write &lt;code&gt;{'k': 1}&lt;/code&gt;, not &lt;code&gt;{'k':1}&lt;/code&gt;, or the parser reads &lt;code&gt;:1}&lt;/code&gt; as a parameter and gets confused.&lt;/p&gt;

&lt;p&gt;Types come back predictably, with one rule worth internalizing. Integers up to 64-bit signed arrive as PHP &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;FLOAT&lt;/code&gt; and &lt;code&gt;DOUBLE&lt;/code&gt; as &lt;code&gt;float&lt;/code&gt;, and &lt;code&gt;BLOB&lt;/code&gt; as a binary string. Everything else, &lt;code&gt;VARCHAR&lt;/code&gt;, the &lt;code&gt;DATE&lt;/code&gt; and &lt;code&gt;TIME&lt;/code&gt; and &lt;code&gt;TIMESTAMP&lt;/code&gt; family, &lt;code&gt;DECIMAL&lt;/code&gt;, the wide &lt;code&gt;HUGEINT&lt;/code&gt; and &lt;code&gt;UBIGINT&lt;/code&gt; integers, and DuckDB's nested types, comes back in its canonical string form. That keeps values lossless when they wouldn't fit a native PHP scalar, at the cost of a cast on your side when you want a number out of a &lt;code&gt;HUGEINT&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;Results are materialized, for now. &lt;code&gt;execute()&lt;/code&gt; buffers the full result set in memory before PDO begins fetching. For the analytical queries DuckDB is built for that is usually fine, but a &lt;code&gt;SELECT&lt;/code&gt; that returns millions of rows is bounded by available memory rather than streamed row by row. True streaming through DuckDB's pending-result API is the next thing on the list.&lt;/p&gt;

&lt;p&gt;One behavior goes the other way, in your favor. When &lt;code&gt;open_basedir&lt;/code&gt; is set, the driver disables DuckDB's SQL-level external file access: &lt;code&gt;read_csv&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;ATTACH&lt;/code&gt;, &lt;code&gt;httpfs&lt;/code&gt;, and the rest. Without that, a single &lt;code&gt;COPY ... TO '/somewhere/outside'&lt;/code&gt; would punch straight through the sandbox &lt;code&gt;open_basedir&lt;/code&gt; is supposed to enforce. The restriction holds at the SQL layer, not just for the path of the database file itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Writing a PDO Driver Looks Like in 2026
&lt;/h2&gt;

&lt;p&gt;The PDO driver model itself has barely moved since I last touched it. You fill in a handful of method tables, hang your connection state off the driver object, and PDO drives the rest. What changed is everything around it.&lt;/p&gt;

&lt;p&gt;On PHP 8.4 and up, &lt;code&gt;PDO::connect('duckdb:...')&lt;/code&gt; returns a &lt;code&gt;Pdo\Duckdb&lt;/code&gt; subclass instance, and &lt;code&gt;duckdbAppender()&lt;/code&gt; lives on that subclass. With &lt;code&gt;new PDO('duckdb:...')&lt;/code&gt;, and on PHP 8.1 through 8.3, the method is reachable on the base object directly. PHP 8.5 starts emitting a deprecation when you call a driver-specific method on the base &lt;code&gt;PDO&lt;/code&gt; class, so on 8.4 and newer you'll want &lt;code&gt;PDO::connect()&lt;/code&gt;. The driver supports both paths; the subclass is just the cleaner one going forward.&lt;/p&gt;

&lt;p&gt;The part that actually cost me a release was the link line. DuckDB ships as a C++ library with a C API bolted on top. The driver is C. The prebuilt Linux binary in 0.2.0 loaded fine on my machine and then died on a clean host with &lt;code&gt;undefined symbol: _ZTVN10__cxxabiv120__function_type_infoE&lt;/code&gt;, the vtable for a C++ ABI type that was supposed to be baked into the binary. The bundled DuckDB C++ runtime was never getting statically linked, because the gcc C driver silently ignores &lt;code&gt;-static-libstdc++&lt;/code&gt;, a flag it treats as g++-only. So the module quietly depended on a libstdc++ the target box didn't ship. &lt;a href="https://github.com/iliaal/pdo_duckdb/releases/tag/0.2.1" rel="noopener noreferrer"&gt;0.2.1&lt;/a&gt; links the static &lt;code&gt;libstdc++&lt;/code&gt; and &lt;code&gt;libgcc_eh&lt;/code&gt; in explicitly, and the Linux binary is genuinely self-contained now. macOS and Windows were never affected, which is its own special way to lose an afternoon chasing the wrong platform.&lt;/p&gt;

&lt;p&gt;Distribution is the other thing that improved while I was away. On Linux x86_64 and arm64, macOS on Apple Silicon, and Windows x64, &lt;a href="https://github.com/php/pie" rel="noopener noreferrer"&gt;PIE&lt;/a&gt; downloads a self-contained prebuilt binary with no DuckDB install and no build toolchain required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pie &lt;span class="nb"&gt;install &lt;/span&gt;iliaal/pdo_duckdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On anything else it falls back to a source build that needs &lt;code&gt;libduckdb&lt;/code&gt; and &lt;code&gt;duckdb.h&lt;/code&gt;. The last time I shipped a PDO driver, this would have been a PECL package and a paragraph of &lt;code&gt;phpize&lt;/code&gt; instructions for the reader to follow. Prebuilt binaries as the default install path is the single biggest quality-of-life change in PHP extension distribution since I started writing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Stands
&lt;/h2&gt;

&lt;p&gt;pdo_duckdb is an early release, and I'd rather say so than oversell it. The query path, the appender, parameter binding, transactions, and type decoding all work and are covered by tests. Streaming is the main thing still on the bench, and the type-mapping surface will keep growing as people push real workloads through it. I haven't published benchmarks, because the speed here is DuckDB's and the driver's job is to stay out of its way.&lt;/p&gt;

&lt;p&gt;If you've wanted DuckDB's analytical engine reachable from PHP without learning a new client API or hand-rolling FFI, this is that, sitting behind the PDO surface you already know.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pie &lt;span class="nb"&gt;install &lt;/span&gt;iliaal/pdo_duckdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/iliaal/pdo_duckdb" rel="noopener noreferrer"&gt;github.com/iliaal/pdo_duckdb&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>duckdb</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Lowering the Floor: What Shipped Across Five PHP Extensions</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Tue, 16 Jun 2026 17:43:07 +0000</pubDate>
      <link>https://dev.to/iliaa/lowering-the-floor-what-shipped-across-five-php-extensions-1dnb</link>
      <guid>https://dev.to/iliaa/lowering-the-floor-what-shipped-across-five-php-extensions-1dnb</guid>
      <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;h2&gt;
  
  
  php_excel 2.2.0: a write mode that can't be tricked into a formula
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Untrusted input written exactly as given, never evaluated as a formula&lt;/span&gt;
&lt;span class="nv"&gt;$sheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$userInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ExcelFormat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AS_TEXT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implicit "leading &lt;code&gt;=&lt;/code&gt; becomes a formula" promotion also got narrowed in 2.2.0. It now fires only when you pass no data type at all. If you explicitly pass a type such as &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 href="https://github.com/iliaal/php_excel" rel="noopener noreferrer"&gt;github.com/iliaal/php_excel&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  php_clickhouse 0.8.7: stream a file straight into ClickHouse
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insertFromStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'event'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'events.tsv'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'TSV'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;50000&lt;/span&gt;          &lt;span class="c1"&gt;// rows per batch&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/php_clickhouse" rel="noopener noreferrer"&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fastchart 1.3.0: vector PDF output
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$chart&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;renderToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'report.pdf'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// or: $pdf = $chart-&amp;gt;renderPdf();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/fastchart" rel="noopener noreferrer"&gt;github.com/iliaal/fastchart&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  fastjson 0.4.0: JSON Pointer, merge-patch, and a relaxed decode mode
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fastjson_pointer_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/users/0/email'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fastjson_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$jsonc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FASTJSON_DECODE_RELAXED&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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 href="https://github.com/iliaal/fastjson" rel="noopener noreferrer"&gt;github.com/iliaal/fastjson&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  phpser 0.2.0: a faster decoder and a closed key-smuggling gap
&lt;/h2&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;div class="table-wrapper-paragraph"&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;/div&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;"5"&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 href="https://github.com/iliaal/phpser" rel="noopener noreferrer"&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>php</category>
      <category>performance</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Generate Too Many UUIDs. So I Wrote a Faster One.</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Sun, 07 Jun 2026 14:13:54 +0000</pubDate>
      <link>https://dev.to/iliaa/i-generate-too-many-uuids-so-i-wrote-a-faster-one-42n4</link>
      <guid>https://dev.to/iliaa/i-generate-too-many-uuids-so-i-wrote-a-faster-one-42n4</guid>
      <description>&lt;p&gt;I generate a lot of UUIDs. Primary keys, cache keys, event IDs, request-trace IDs. Probably too many, if I'm honest about it. On a busy request path the same function gets called dozens of times before the response is even assembled, and across a fleet that adds up to a number of UUIDs per second I would rather not write down.&lt;/p&gt;

&lt;p&gt;For years that cost was invisible to me, the way a single &lt;code&gt;random_bytes()&lt;/code&gt; call is invisible until you make a few billion of them. Then it showed up in a profile, sitting higher than it had any right to, and I started paying attention to where the time actually went. It went to two places: pulling fresh entropy from the kernel once per UUID, and formatting 16 raw bytes into the 36-character canonical string. Both are cheap. Neither is free. Multiply by "too many" and you get a real slice of CPU spent doing nothing but minting identifiers.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;a href="https://github.com/iliaal/fast_uuid" rel="noopener noreferrer"&gt;&lt;code&gt;fast_uuid&lt;/code&gt;&lt;/a&gt;, a PHP extension that does UUID generation in pure C. This post is about why the two existing options each left a gap, what the extension does differently, and what it costs you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two options I already had, and where each one stopped
&lt;/h2&gt;

&lt;p&gt;PHP developers generating UUIDs today reach for one of two things, and both are good at what they do.&lt;/p&gt;

&lt;p&gt;The PECL &lt;code&gt;uuid&lt;/code&gt; extension wraps libuuid from util-linux. It is fast at version 1 (time-based) UUIDs, because libuuid has a tuned generator for them. But it has three problems for my use case. Its version coverage is partial: v1 and v4 are reliable, v3 and v5 live behind separate &lt;code&gt;uuid_generate_md5()&lt;/code&gt; / &lt;code&gt;uuid_generate_sha1()&lt;/code&gt; functions, v6 and v7 only compile in if you built against a recent enough libuuid, and v8 is absent entirely. Its v4 path is slow, because it asks the kernel for fresh entropy on every call. And its API looks nothing like the &lt;code&gt;ramsey/uuid&lt;/code&gt; code most projects are already written against, so adopting it means rewriting every call site.&lt;/p&gt;

&lt;p&gt;There is also a licensing wrinkle worth stating precisely, because it is easy to get wrong. libuuid itself is BSD-3-Clause and permissive. The PECL &lt;code&gt;uuid&lt;/code&gt; extension that binds it, however, is LGPL-2.1-or-later. If you only ever &lt;code&gt;apt install&lt;/code&gt; the package that's no issue, but the moment you want to vendor the binding, ship it inside a permissively-licensed product, or relicense around it, the copyleft on the PHP binding is friction the BSD library underneath does not have.&lt;/p&gt;

&lt;p&gt;The other option is &lt;code&gt;ramsey/uuid&lt;/code&gt;, and it is excellent. MIT-licensed, every RFC version, ULID support, an API that has become the de facto standard in the Laravel and Symfony worlds. I have used it for years and have no complaints about its correctness or its ergonomics. The one thing it can't escape is that it's PHP. Its v4 generator calls &lt;code&gt;random_bytes()&lt;/code&gt; once per UUID, and that syscall dominates a job that is otherwise a few dozen nanoseconds of work. Its v1 path is its slowest, because the clock-sequence and node bookkeeping all happen in PHP. And for UUIDv7, which I now reach for on time-ordered primary keys, &lt;code&gt;getDateTime()&lt;/code&gt; hands you a &lt;code&gt;DateTimeImmutable&lt;/code&gt; at millisecond resolution with no cheap integer-millisecond path to skip the object construction entirely.&lt;/p&gt;

&lt;p&gt;So the gap was specific. One option is fast on a narrow set of versions but licensed awkwardly and API-incompatible. The other is broad and beautifully designed but pays a PHP-level cost on the hot path. Nobody covered speed and full RFC 9562 coverage and a familiar API at the same time. I wanted all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "tackle both" actually meant
&lt;/h2&gt;

&lt;p&gt;The design target was three things at once, none of them negotiable.&lt;/p&gt;

&lt;p&gt;Full RFC 9562 coverage: versions 1, 2 (DCE Security), 3, 4, 5, 6, 7, 8, plus nil and max. Not "the popular ones." All of them, so the extension is never the reason you can't use a version.&lt;/p&gt;

&lt;p&gt;An API a &lt;code&gt;ramsey/uuid&lt;/code&gt; user already knows. The object API mirrors &lt;code&gt;ramsey/uuid&lt;/code&gt; under the &lt;code&gt;FastUuid&lt;/code&gt; namespace, so the cold-path ergonomics are familiar. For the hot path there is a procedural, zero-allocation set of functions (&lt;code&gt;uuid_v4()&lt;/code&gt;, &lt;code&gt;uuid_v7()&lt;/code&gt;, and friends) that return a &lt;code&gt;zend_string&lt;/code&gt; directly with no object to allocate or free.&lt;/p&gt;

&lt;p&gt;Pure C, no C++. No libstdc++ to link against, no external UUID library to track for version skew or licensing. The whole thing is BSD-3-Clause, including the entropy and formatting paths, so there is no copyleft binding sitting between you and a permissive license.&lt;/p&gt;

&lt;p&gt;The rest of the post is the two optimizations that close the speed gap, the UUIDv7 work, the numbers, and the honest costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why ramsey/uuid spends its time in random_bytes, and what to do instead
&lt;/h2&gt;

&lt;p&gt;The short version: &lt;code&gt;random_bytes()&lt;/code&gt; is a syscall, and a syscall per UUID is the bottleneck. &lt;code&gt;fast_uuid&lt;/code&gt; makes one kernel entropy request and serves hundreds of UUIDs from it before going back.&lt;/p&gt;

&lt;p&gt;Generating a v4 UUID is 16 random bytes with six bits overwritten for version and variant. The randomness is the entire job. In &lt;code&gt;ramsey/uuid&lt;/code&gt; that randomness comes from &lt;code&gt;random_bytes(16)&lt;/code&gt;, which on Linux funnels into the &lt;code&gt;getrandom()&lt;/code&gt; syscall. Crossing into the kernel and back costs far more than the handful of nanoseconds it takes to set the version bits, so at scale you're not paying for UUID generation, you're paying for syscalls.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fast_uuid&lt;/code&gt; keeps an 8 KB per-thread buffer and fills it with a single &lt;code&gt;getrandom()&lt;/code&gt; call. At 16 bytes per v4 UUID that one syscall covers roughly 500 UUIDs before the buffer needs refilling. The entropy is exactly as fresh, it comes from the same kernel CSPRNG, you just amortize the crossing cost across the batch instead of paying it every call. That single change is most of the order-of-magnitude gap on v4.&lt;/p&gt;

&lt;p&gt;For callers who want raw speed on identifiers that are not security-sensitive, there is also &lt;code&gt;uuid_v4_fast()&lt;/code&gt;, which draws from a xoshiro256** PRNG instead of the kernel. That's a deliberately non-cryptographic generator. It's for ORM keys and trace IDs where unpredictability isn't a security property, and I'll say plainly in the next section why you must never reach for it when it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning 16 bytes into 32 hex characters without a loop
&lt;/h2&gt;

&lt;p&gt;The other half of the cost is formatting. The answer-first version: converting the binary UUID to its canonical hex string is a per-byte lookup loop in most implementations, and a single SIMD table-lookup instruction can do 16 bytes at once.&lt;/p&gt;

&lt;p&gt;The canonical string is the 16 bytes expanded to 32 hex characters with four dashes inserted. Done scalar, that is a loop with two nibble lookups per byte. &lt;code&gt;fast_uuid&lt;/code&gt; does it with one vector instruction over the whole 16 bytes: &lt;code&gt;pshufb&lt;/code&gt; on x86-64 (SSSE3), &lt;code&gt;vqtbl1q_u8&lt;/code&gt; on ARM64 (NEON), both of which are byte-shuffle table lookups that turn a vector of nibbles into a vector of ASCII hex digits in a single shot. The extension picks which to use at runtime from CPU feature detection, and falls back to a scalar lookup table on anything without those instruction sets. There are no build flags to set and no &lt;code&gt;-march&lt;/code&gt; to remember; the extension chooses the right path when it loads.&lt;/p&gt;

&lt;p&gt;The object itself is built for the same frugality. It is 16 inline bytes plus a lazily-cached canonical string, with no &lt;code&gt;HashTable&lt;/code&gt; and no declared properties, so there is no per-object property storage to allocate and tear down.&lt;/p&gt;

&lt;h2&gt;
  
  
  UUIDv7 with sub-millisecond ordering, and a thank-you to Ben Ramsey
&lt;/h2&gt;

&lt;p&gt;UUIDv7 is the version I care most about, because it gives you time-ordered, index-friendly primary keys without a separate sortable column. The answer-first claim: &lt;code&gt;fast_uuid&lt;/code&gt; keeps v7 UUIDs in correct time order even when many are generated inside the same millisecond, and gives you an integer-millisecond path that skips &lt;code&gt;DateTime&lt;/code&gt; entirely.&lt;/p&gt;

&lt;p&gt;The v7 layout is a 48-bit Unix millisecond timestamp, then the version and variant bits, then &lt;code&gt;rand_a&lt;/code&gt; (12 bits) and &lt;code&gt;rand_b&lt;/code&gt; (62 bits). The problem with a plain implementation is that two UUIDs minted in the same millisecond have the same timestamp and random tails, so their sort order within that millisecond is arbitrary. RFC 9562 anticipates this in section 6.2. Its Method 3, "replace leftmost random bits with increased clock precision," puts a sub-millisecond clock fraction into the leftmost bits of &lt;code&gt;rand_a&lt;/code&gt;. &lt;code&gt;fast_uuid&lt;/code&gt; does exactly that, and adds a monotonic counter in &lt;code&gt;rand_b&lt;/code&gt;, so same-millisecond v7s still sort in generation order. Your database index stays happy.&lt;/p&gt;

&lt;p&gt;On top of the ordering work there is an integer-millisecond API: &lt;code&gt;uuid_v7_at(int $ms)&lt;/code&gt;, &lt;code&gt;Uuid::uuid7(int $ms)&lt;/code&gt;, and &lt;code&gt;getTimestampMillis()&lt;/code&gt;. These let you stamp a UUID at a known time, or read its time back, as a plain integer, without constructing a &lt;code&gt;DateTime&lt;/code&gt; object on either side. The DateTime accessors that do exist read and write ext/date's internal &lt;code&gt;timelib_time&lt;/code&gt; structure directly rather than routing through &lt;code&gt;call_user_function&lt;/code&gt;, which is roughly three times cheaper.&lt;/p&gt;

&lt;p&gt;Conversations with Ben Ramsey sharpened some of these choices. He was generous with feedback while I was building this, and he shared where he's taking identifiers next with &lt;code&gt;ramsey/identifier&lt;/code&gt;, a newer library spanning UUIDs, ULIDs, and Snowflake IDs that points at the direction he sees superseding &lt;code&gt;ramsey/uuid&lt;/code&gt; over time. That nudged me toward future-proofing the API surface: alongside the &lt;code&gt;get*&lt;/code&gt; method names every &lt;code&gt;ramsey/uuid&lt;/code&gt; user knows, &lt;code&gt;fast_uuid&lt;/code&gt; also ships &lt;code&gt;to*&lt;/code&gt; aliases, so code written today reads the same whichever way the wider convention settles. Credit where it is due. &lt;code&gt;ramsey/uuid&lt;/code&gt; set the bar I was building against, and Ben pointing at what comes after it shaped the parts of this extension meant to outlive the current convention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Throughput against &lt;code&gt;ramsey/uuid&lt;/code&gt; 4.9.2 and the PECL &lt;code&gt;uuid&lt;/code&gt; extension 1.3.0, on PHP 8.4.22 NTS, non-debug, no sanitizers, with the SSSE3 hex formatter active on x86-64. Each operation runs 300,000 iterations after a 20,000-iteration warmup, and the reported figure is the best of 40 runs. Units are million operations per second, higher is better.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;fast_uuid (obj)&lt;/th&gt;
&lt;th&gt;fast_uuid (proc)&lt;/th&gt;
&lt;th&gt;ramsey/uuid&lt;/th&gt;
&lt;th&gt;PECL uuid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v4 gen to string&lt;/td&gt;
&lt;td&gt;12.6&lt;/td&gt;
&lt;td&gt;19.5&lt;/td&gt;
&lt;td&gt;1.10&lt;/td&gt;
&lt;td&gt;0.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1 gen to string&lt;/td&gt;
&lt;td&gt;12.3&lt;/td&gt;
&lt;td&gt;16.5&lt;/td&gt;
&lt;td&gt;0.29&lt;/td&gt;
&lt;td&gt;8.22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v7 gen to string&lt;/td&gt;
&lt;td&gt;12.1&lt;/td&gt;
&lt;td&gt;19.8&lt;/td&gt;
&lt;td&gt;0.66&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;parse to 16 bytes&lt;/td&gt;
&lt;td&gt;10.4&lt;/td&gt;
&lt;td&gt;16.2&lt;/td&gt;
&lt;td&gt;3.18&lt;/td&gt;
&lt;td&gt;5.28&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Against &lt;code&gt;ramsey/uuid&lt;/code&gt; that is roughly 11.5x to 17.7x on v4, 42x to 57x on v1, 18.3x to 30x on v7, and 3.3x to 5.1x on parsing. The v1 gap is the widest because v1 is ramsey/uuid's slowest path and close to fast_uuid's fastest. Note also that PECL is faster than ramsey/uuid on v1 (8.22 vs 0.29) and that this is the one row where PECL is strong, yet fast_uuid still clears it, while on v4 PECL drops to 0.47 because it asks the kernel for entropy every call.&lt;/p&gt;

&lt;p&gt;One honest caveat that I keep in &lt;code&gt;BENCHMARKS.md&lt;/code&gt; and will not drop here: the &lt;code&gt;fast_uuid&lt;/code&gt; operations are fast enough (around 50 ns) that scheduler noise dominates a single run, so read the &lt;code&gt;fast_uuid&lt;/code&gt; columns as order-of-magnitude rather than three-significant-figure, roughly plus or minus 10 percent run to run. The &lt;code&gt;ramsey/uuid&lt;/code&gt; (around 900 ns) and PECL (around 2 microseconds) columns reproduce to within about 3 percent. If you cite these numbers, cite them with the comparison set and build: vs &lt;code&gt;ramsey/uuid&lt;/code&gt; 4.9.2 and PECL &lt;code&gt;uuid&lt;/code&gt; 1.3.0, PHP 8.4.22 NTS non-debug, best of 40 runs. ARM64 NEON numbers and the full timestamp-API table are in the repo.&lt;/p&gt;

&lt;p&gt;For a byte-layout-heavy extension the more important number than throughput is correctness. It builds green on PHP 8.1 through 8.6, NTS and ZTS, with zero compiler warnings, and runs clean under AddressSanitizer and UndefinedBehaviorSanitizer across five build configurations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs you
&lt;/h2&gt;

&lt;p&gt;No optimization is free, and hiding the costs would defeat the point of writing this honestly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;uuid_v4_fast()&lt;/code&gt; uses xoshiro256**, which is fast and statistically good but not cryptographically secure. Use it for keys and trace IDs where unpredictability is not a security requirement. Never use it for session tokens, password-reset nonces, or anything an attacker benefits from guessing. The kernel-backed &lt;code&gt;uuid_v4()&lt;/code&gt; is right there for those.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ramsey/uuid&lt;/code&gt; compatibility layer, &lt;code&gt;FastUuid\Compat&lt;/code&gt;, is a PSR-4 companion package that makes migration largely a &lt;code&gt;use&lt;/code&gt; swap. It is not on Packagist yet, so today you install it as a Composer path repository rather than a plain &lt;code&gt;composer require&lt;/code&gt;. Adoption is a migration, not a binary swap.&lt;/p&gt;

&lt;p&gt;If you supply a custom &lt;code&gt;RandomGeneratorInterface&lt;/code&gt;, &lt;code&gt;TimeGeneratorInterface&lt;/code&gt;, or &lt;code&gt;NodeProviderInterface&lt;/code&gt;, generation intentionally routes off the C fast path, the same way ramsey/uuid lets you override its internals. Your generator wins, and you give up the speedup for those calls. That is the correct trade, but it is a trade.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;getDateTime()&lt;/code&gt; reads v7 timestamps back at millisecond precision, matching &lt;code&gt;ramsey/uuid&lt;/code&gt;, even though the extension carries sub-millisecond data internally for ordering. The sub-ms fraction exists to keep same-millisecond UUIDs sorted, not to hand you back a nanosecond clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it
&lt;/h2&gt;

&lt;p&gt;The extension is BSD-3-Clause, builds on PHP 8.1 through 8.6, and is PIE-installable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pie &lt;span class="nb"&gt;install &lt;/span&gt;iliaal/fast_uuid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prebuilt binaries cover Windows x86/x64 (NTS and TS), Linux glibc x86_64 and arm64, and macOS arm64. Source, benchmarks, and the compatibility layer are at &lt;a href="https://github.com/iliaal/fast_uuid" rel="noopener noreferrer"&gt;github.com/iliaal/fast_uuid&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;UUID generation is the kind of cost you never notice until you're doing it a million times an hour, and then it's the kind you can't un-see. &lt;code&gt;fast_uuid&lt;/code&gt; gives most of that cost back without asking you to relearn an API or drop a single RFC 9562 version, and it gets a little faster every release.&lt;/p&gt;

</description>
      <category>php</category>
      <category>performance</category>
      <category>uuid</category>
      <category>showdev</category>
    </item>
    <item>
      <title>phpser: a fast, secure binary serializer for PHP cache workloads</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Tue, 02 Jun 2026 18:53:39 +0000</pubDate>
      <link>https://dev.to/iliaa/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads-4hn9</link>
      <guid>https://dev.to/iliaa/phpser-a-fast-secure-binary-serializer-for-php-cache-workloads-4hn9</guid>
      <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;h2&gt;
  
  
  Why a serializer built for caches?
&lt;/h2&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-asymmetry.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-asymmetry.svg" alt="Encode runs once per write; decode runs on every read" width="776" height="158"&gt;&lt;/a&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;h2&gt;
  
  
  Designing the format for the reader
&lt;/h2&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;h2&gt;
  
  
  A string dictionary, and an intern that survives decode
&lt;/h2&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;"created_at"&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;"user_id"&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-wire-format.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-wire-format.svg" alt="The dictionary is emitted once at the head; values reference it by varint index, and repeated strings reuse one interned zend_string by refcount" width="776" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fast to encode, too
&lt;/h2&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;"id"&lt;/code&gt; in row 1 and the &lt;code&gt;"id"&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;h2&gt;
  
  
  Tagged scalar runs, and building the array in place
&lt;/h2&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-build-in-place.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-build-in-place.svg" alt="A naive decoder hashes and grows the table per element; phpser allocates once from the header count and writes slots directly" width="776" height="218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmarks
&lt;/h2&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;div class="table-wrapper-paragraph"&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;/div&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;h2&gt;
  
  
  Where it gives a little back
&lt;/h2&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;h2&gt;
  
  
  Signed payloads: safe to decode from an untrusted cache
&lt;/h2&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-signed-trust-boundary.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Filia.ws%2Fuser%2Fpages%2F01.blog%2F20260602-phpser-a-fast-secure-binary-serializer-for-php-cache-workloads%2Fphpser-signed-trust-boundary.svg" alt="Native unserialize decodes attacker bytes before checking anything; the signed path verifies the HMAC first and returns null on mismatch, so nothing is decoded" width="776" height="258"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// generate once, keep it in app config or a secrets manager&lt;/span&gt;

&lt;span class="c1"&gt;// on write&lt;/span&gt;
&lt;span class="nv"&gt;$blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;phpser_serialize_signed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cacheValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user:42'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// on read&lt;/span&gt;
&lt;span class="nv"&gt;$blob&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$redis&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user:42'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;phpser_unserialize_signed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// tampered, truncated, or signed with a different key.&lt;/span&gt;
    &lt;span class="c1"&gt;// nothing was decoded; treat it as a cache miss and rebuild.&lt;/span&gt;
    &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rebuild_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reject every class: unknown objects decode as __PHP_Incomplete_Class, never instantiated&lt;/span&gt;
&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;phpser_unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'allowed_classes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// or allowlist only the classes you actually expect to read back&lt;/span&gt;
&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;phpser_unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'allowed_classes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;UserDto&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OrderDto&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;h2&gt;
  
  
  What I took away
&lt;/h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pie &lt;span class="nb"&gt;install &lt;/span&gt;iliaal/phpser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source, wire-format spec, and the bench harness: &lt;a href="https://github.com/iliaal/phpser" rel="noopener noreferrer"&gt;github.com/iliaal/phpser&lt;/a&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>performance</category>
      <category>serialization</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
