<?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>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>
    <item>
      <title>Fastchart 1.X, why I rewrote it after 0.2 release</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Fri, 22 May 2026 12:16:57 +0000</pubDate>
      <link>https://dev.to/iliaa/fastchart-1x-why-i-rewrote-it-after-02-release-4bhg</link>
      <guid>https://dev.to/iliaa/fastchart-1x-why-i-rewrote-it-after-02-release-4bhg</guid>
      <description>&lt;p&gt;Couple of weeks ago I shipped fastchart 0.2.0 and &lt;a href="https://ilia.ws/blog/fastchart-0-2-0-native-php-charts-barcodes-and-qr-codes-in-one-extension" rel="noopener noreferrer"&gt;wrote it up here&lt;/a&gt;. One extension, 19 chart types, server-side rendering through &lt;code&gt;ext/gd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The idea was right, but execution not quite there.&lt;/p&gt;

&lt;p&gt;After the launch I spent a few days actually looking at the output side by side with what plutovg can do on the same primitives. The libgd-rendered charts were fine for what libgd is, which is a 1990s 2D rasterizer with no subpixel-precise anti-aliasing. Diagonal lines were jaggy. Glyph edges grainy at small sizes. The output read as "a chart drawn by ext/gd in 2026" instead of "a chart." A couple of Reddit threads (r/PHP and r/laravel) pointed at the same thing without me having to ask.&lt;/p&gt;

&lt;p&gt;So I rewrote it. fastchart 1.1.1 is the current stable of that rewrite, following a number of stabilization tweaks and refinements, the end result being SVG promoted to the canonical render format, and the chart-family count up from 19 to 26.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was wrong with the libgd output
&lt;/h2&gt;

&lt;p&gt;libgd's anti-aliasing is &lt;code&gt;gdAntiAliased&lt;/code&gt; plus the alpha channel. It works for solid-fill regions. It falls apart on the boundary between two filled regions of different colors, on glyph rendering at body-text sizes, and on rotated text where the rotation axis isn't a 45° multiple.&lt;/p&gt;

&lt;p&gt;Specific cases I kept hitting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Diagonal series lines in a &lt;code&gt;LineChart&lt;/code&gt; showed visible stair-stepping at 1px stroke width. libgd's &lt;code&gt;gdImageLine&lt;/code&gt; with &lt;code&gt;gdAntiAliased&lt;/code&gt; blends against the canvas background, not the local color, so a red line crossing a blue series region looked correct on the white margin and wrong inside the plot area.&lt;/li&gt;
&lt;li&gt;45° rotated X-axis labels (the default when categorical labels are long) had a noticeable diagonal moiré along the baseline. Different from the line issue: this came from libgd's FreeType bridge, which renders each glyph straight then resamples the resulting bitmap. The double rasterization loses sharpness.&lt;/li&gt;
&lt;li&gt;StockChart hollow candles drew the body outline with the same primitive as the wick, so the body edges and the wick had the same line weight even when they shouldn't. The fix would have been a separate primitive; libgd has no concept of stroke vs fill weight at the polyline level.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are libgd bugs. They are libgd doing exactly what libgd has always done. They are also not things I can fix in a wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SVG won
&lt;/h2&gt;

&lt;p&gt;The shortest path from "I want sharper output" to "the output is sharper" is to stop rasterizing in the render path at all. SVG is text. The render path appends strings into a &lt;code&gt;smart_str&lt;/code&gt;. The reader (a browser, an inkscape, a plutovg) does the rasterization at the resolution and pixel density it wants.&lt;/p&gt;

&lt;p&gt;That solves the visual-quality complaint by default. It also gives back DPI-independent output: a chart embedded in a PDF or rendered at retina density on a Mac no longer needs &lt;code&gt;setDpi(192)&lt;/code&gt; to look right.&lt;/p&gt;

&lt;p&gt;Not everyone wants or needs SVG, so the architecture needs a path for traditional image formats too (PNG / JPG):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every chart family builds an SVG document.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;renderSvg()&lt;/code&gt; returns the document bytes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;renderPng()&lt;/code&gt; / &lt;code&gt;renderJpeg()&lt;/code&gt; / &lt;code&gt;renderWebp()&lt;/code&gt; flatten the SVG's &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; elements to glyph outline paths via FreeType, run the result through plutovg + plutosvg (vendored, both MIT-licensed), and encode the rasterized RGBA buffer with libpng / libjpeg-turbo / libwebp.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same chart object, four render methods, four outputs. No second code path for rasters; the raster encoders sit downstream of the same vector builder.&lt;/p&gt;

&lt;p&gt;The cost is that fastchart now links against four codec libs instead of one. The benefit is libgd is gone, both as a build dependency (no &lt;code&gt;libgd-dev&lt;/code&gt; apt package required) and as a runtime requirement (the chart classes no longer touch &lt;code&gt;GdImage&lt;/code&gt; at all).&lt;/p&gt;

&lt;h2&gt;
  
  
  The 1.x arc
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1.0.0: the rewrite
&lt;/h3&gt;

&lt;p&gt;The architectural pivot. SVG canonical, libgd dropped, plutovg + plutosvg vendored. Seven new chart families lifted the family count from 19 to 26: &lt;code&gt;BulletChart&lt;/code&gt;, &lt;code&gt;ParetoChart&lt;/code&gt;, &lt;code&gt;CalendarHeatmap&lt;/code&gt;, &lt;code&gt;SunburstChart&lt;/code&gt;, &lt;code&gt;SankeyChart&lt;/code&gt;, &lt;code&gt;MarimekkoChart&lt;/code&gt;, &lt;code&gt;VectorChart&lt;/code&gt;. All seven were sitting in the same private-extensions queue as the 0.2.0 set; pulling them into the public repo was easier once the architecture was settled.&lt;/p&gt;

&lt;p&gt;Breaking changes from 0.2.0: no more GIF support, and no more GdImage canvas input/output.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.0.(1-2): build portability
&lt;/h3&gt;

&lt;p&gt;1.0.1 added prebuilt binaries via PIE for Windows (PHP 8.3 / 8.4 / 8.5, NTS + TS, x64 + x86) and Linux x86_64 / arm64 + macOS arm64 (PHP 8.4 / 8.5 NTS). 1.0.2 fixed a libjpeg soname mismatch that broke the Linux prebuilts on Debian-based PHP, including every official &lt;code&gt;php:X.Y-cli&lt;/code&gt; Docker image. The fix statically links libjpeg-turbo from a configure-time source build, with &lt;code&gt;-Wl,--exclude-libs=ALL&lt;/code&gt; so the static symbols stay local to &lt;code&gt;fastchart.so&lt;/code&gt; and don't collide with the system &lt;code&gt;ext/gd&lt;/code&gt; in the same process.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1.0: features on the new pipeline
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;AreaChart::setBandMode(bool)&lt;/code&gt; for confidence-interval and min-max envelope fills. &lt;code&gt;PolarChart::setInterpolation(INTERP_SMOOTH)&lt;/code&gt; for Catmull-Rom-subdivided polar curves. &lt;code&gt;PolarChart::addVectors()&lt;/code&gt; to overlay arrow vectors in &lt;code&gt;(angle, radius)&lt;/code&gt; data space. &lt;code&gt;Funnel::setStyle(STYLE_CONE)&lt;/code&gt; for a pyramid with front-facing ellipse-arc edges. &lt;code&gt;BubbleChart::setYAxisScale(SCALE_LOG)&lt;/code&gt;. And &lt;code&gt;Chart::setImageMap()&lt;/code&gt; + &lt;code&gt;getImageMap()&lt;/code&gt; for HTML image-map hot-spots on BarChart, PieChart, and ScatterChart, with allowlisted URL schemes (http / https / mailto / &lt;code&gt;/&lt;/code&gt; / &lt;code&gt;#&lt;/code&gt;) and HTML-escaped tooltip text.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1.1: hardening
&lt;/h3&gt;

&lt;p&gt;Shipped today. No new APIs, no breaking changes. Fourteen fixes to improve code quality. A non-exhaustive sample:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PolarChart::addVectors()&lt;/code&gt; rejects NaN / Inf at the boundary now, matching every other polar setter. The float-to-int cast it was feeding is UB per C11 §6.3.1.4p1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MarimekkoChart::setColumns()&lt;/code&gt; running-sum overflow. Each individual segment value was &lt;code&gt;isfinite&lt;/code&gt;-guarded, but the per-column sum and cross-column total could overflow to &lt;code&gt;+Inf&lt;/code&gt; from two segments at &lt;code&gt;1e308&lt;/code&gt; each. Columns whose running sum is non-finite are now silently dropped.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StockChart&lt;/code&gt; &lt;code&gt;STYLE_VECTOR&lt;/code&gt; climax-deque ring-buffer overflow. After 11 strictly-decreasing pushes the deque tail wrapped to the head and every subsequent bar got misclassified as a climax. The stale-front drop now runs before the push.&lt;/li&gt;
&lt;li&gt;JPEG encoder local pointers carry &lt;code&gt;volatile&lt;/code&gt; across &lt;code&gt;setjmp()&lt;/code&gt;. Per C99 §7.13.2.1 a register-spilled local that libjpeg's &lt;code&gt;longjmp&lt;/code&gt; clobbers is UB; the cleanup branch could leak or double-free.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fc_ft_measure()&lt;/code&gt; was a 1 / 2 / 3-byte UTF-8 decoder. Non-BMP codepoints (emoji, CJK extensions, mathematical alphanumeric symbols) contributed 0 to the measured width, so chart layout reservations came up systematically narrow on labels containing emoji. 4-byte branch added.&lt;/li&gt;
&lt;li&gt;Time-axis arithmetic on &lt;code&gt;setOhlcv&lt;/code&gt; timestamps near &lt;code&gt;LLONG_MIN..LLONG_MAX&lt;/code&gt; overflowed signed &lt;code&gt;zend_long&lt;/code&gt; in four call sites. Promoted to double arithmetic; out-of-range double-to-&lt;code&gt;zend_long&lt;/code&gt; casts clamped.&lt;/li&gt;
&lt;li&gt;Vendor refresh: plutosvg 0.0.7 to 0.0.8, plutovg 1.3.2 to 1.3.3. Most of fastchart's local patches (UTF-8 BOM handling, FT include, &lt;code&gt;sprintf&lt;/code&gt; to &lt;code&gt;snprintf&lt;/code&gt;, bilinear tile sampling) are now upstream. Preserved fastchart-only: the stb_image PNG palette zero-init and the &lt;code&gt;STBI_ONLY_PNG / STBI_ONLY_JPEG&lt;/code&gt; cut that drops ~70% of the stb image-loader binary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full list in &lt;a href="https://github.com/iliaal/fastchart/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;CHANGELOG.md&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outside contributions
&lt;/h3&gt;

&lt;p&gt;Along the way the 1.x cycle caught the attention of &lt;a href="https://github.com/remicollet" rel="noopener noreferrer"&gt;Remi Collet&lt;/a&gt;, who runs the &lt;a href="https://rpms.remirepo.net/" rel="noopener noreferrer"&gt;rpms.remirepo.net&lt;/a&gt; PHP RPM repository (the standard PHP RPM source for RHEL / Fedora / CentOS). Three merged PRs landed between 1.1.0 and 1.1.1: a test-path correction so the suite finds the build directory regardless of out-of-tree builds, a fix for an undefined &lt;code&gt;$ext_builddir&lt;/code&gt; that broke the Fedora PHP build glue, and an additional Fedora-family font-path probe (&lt;code&gt;/usr/share/fonts/lato/&lt;/code&gt;, &lt;code&gt;/usr/share/fonts/TTF/&lt;/code&gt;) for MINIT's default-font detection. The cumulative effect is that fastchart now builds and runs cleanly under Remi's RPM packaging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance: what each format actually costs
&lt;/h2&gt;

&lt;p&gt;Median in-memory render time at 1920×1080 on a single core, Intel i9-13950HX, PHP 8.4 debug build, default font and DPI. SVG is the canonical output; PNG / WebP / JPG add their rasterize + encode cost on top of the SVG build.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chart&lt;/th&gt;
&lt;th&gt;SVG ms&lt;/th&gt;
&lt;th&gt;PNG ms&lt;/th&gt;
&lt;th&gt;WebP ms&lt;/th&gt;
&lt;th&gt;JPG ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AreaChart&lt;/td&gt;
&lt;td&gt;5.7&lt;/td&gt;
&lt;td&gt;71.7&lt;/td&gt;
&lt;td&gt;54.4&lt;/td&gt;
&lt;td&gt;34.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BarChart&lt;/td&gt;
&lt;td&gt;11.0&lt;/td&gt;
&lt;td&gt;75.8&lt;/td&gt;
&lt;td&gt;57.0&lt;/td&gt;
&lt;td&gt;38.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BoxPlot&lt;/td&gt;
&lt;td&gt;4.4&lt;/td&gt;
&lt;td&gt;65.0&lt;/td&gt;
&lt;td&gt;47.5&lt;/td&gt;
&lt;td&gt;31.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BubbleChart&lt;/td&gt;
&lt;td&gt;2.6&lt;/td&gt;
&lt;td&gt;90.9&lt;/td&gt;
&lt;td&gt;62.7&lt;/td&gt;
&lt;td&gt;38.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GaugeChart&lt;/td&gt;
&lt;td&gt;1.2&lt;/td&gt;
&lt;td&gt;69.4&lt;/td&gt;
&lt;td&gt;49.7&lt;/td&gt;
&lt;td&gt;29.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LineChart&lt;/td&gt;
&lt;td&gt;5.4&lt;/td&gt;
&lt;td&gt;74.2&lt;/td&gt;
&lt;td&gt;55.5&lt;/td&gt;
&lt;td&gt;34.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PieChart&lt;/td&gt;
&lt;td&gt;2.8&lt;/td&gt;
&lt;td&gt;71.6&lt;/td&gt;
&lt;td&gt;51.9&lt;/td&gt;
&lt;td&gt;33.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StockChart&lt;/td&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;80.8&lt;/td&gt;
&lt;td&gt;63.3&lt;/td&gt;
&lt;td&gt;41.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SVG sits between 1.2 ms and 11 ms across all 19 measured types. There is no rasterizer in that path; the backend appends strings into a &lt;code&gt;smart_str&lt;/code&gt; and returns the bytes. The raster encoders split into three bands: JPG fastest at 25 to 41 ms (libjpeg-turbo with 4:2:0 subsampling), WebP middle at 41 to 63 ms (libwebp with &lt;code&gt;WEBP_PRESET_DRAWING&lt;/code&gt; + method=2 + multi-thread, roughly 2× faster than the simple API the rewrite started with), PNG slowest at 62 to 91 ms (libpng's deflate dominates). All four formats stay under 95 ms at 1080p on one thread.&lt;/p&gt;

&lt;p&gt;Full table for all 19 measured chart types in the &lt;a href="https://github.com/iliaal/fastchart#-performance" rel="noopener noreferrer"&gt;README perf section&lt;/a&gt;. Reproduce with &lt;code&gt;php -d extension=./modules/fastchart.so docs/bench/bench.php&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composing without a shared canvas
&lt;/h2&gt;

&lt;p&gt;The 0.2.0 differentiator was &lt;code&gt;draw(GdImage $canvas)&lt;/code&gt;: hand fastchart a gd canvas you owned, it drew into it, you composited multiple charts on one image. That API is gone in 1.x because v1.x owns its pixel buffer.&lt;/p&gt;

&lt;p&gt;The replacement is &lt;code&gt;drawSvgFragment()&lt;/code&gt; plus the static &lt;code&gt;svgToPng / svgToJpeg / svgToWebp&lt;/code&gt; rasterizers:&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;$line&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;FastChart\LineChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;800&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Daily active users'&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;setSeries&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$values&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;

&lt;span class="nv"&gt;$bar&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;FastChart\BarChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;800&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Quarterly revenue'&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;setSeries&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$bars&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;

&lt;span class="c1"&gt;// Stitch two fragments into one outer SVG document.&lt;/span&gt;
&lt;span class="nv"&gt;$outer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;&amp;lt;&amp;lt;&amp;lt;SVG
&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;
&amp;lt;svg xmlns="http://www.w3.org/2000/svg" width="1600" height="600" viewBox="0 0 1600 600"&amp;gt;
  &amp;lt;g transform="translate(0,0)"&amp;gt;{$line-&amp;gt;drawSvgFragment()}&amp;lt;/g&amp;gt;
  &amp;lt;g transform="translate(800,0)"&amp;gt;{$bar-&amp;gt;drawSvgFragment()}&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
SVG;&lt;/span&gt;

&lt;span class="c1"&gt;// Take the stitched SVG either as bytes or rasterized in one call.&lt;/span&gt;
&lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/tmp/dashboard.svg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$outer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;file_put_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/tmp/dashboard.png'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FastChart\Chart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;svgToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$outer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same dashboard story as the 0.2.0 example, no shared canvas. The rasterizer sees the full composed document and antialiases across fragment boundaries cleanly.&lt;/p&gt;

&lt;p&gt;On Linux with a standard fonts package installed (Ubuntu's &lt;code&gt;fonts-lato&lt;/code&gt;, Debian's &lt;code&gt;fonts-dejavu&lt;/code&gt;, etc.) fastchart's MINIT auto-probes a default. On macOS or in minimal containers, add &lt;code&gt;-&amp;gt;setFontPath('/path/to/font.ttf')&lt;/code&gt; on each chart so labels render.&lt;/p&gt;

&lt;p&gt;The SVG-canonical pipeline also gave a freebie: &lt;code&gt;svgToPng / svgToJpeg / svgToWebp&lt;/code&gt; rasterize any caller-supplied SVG bytes through the same plutovg + encoder path, not just fastchart's own output. If you need to convert an arbitrary SVG to PNG / JPG / WebP from PHP without shelling out to ImageMagick, rsvg-convert, or Inkscape, that's now in-process. The caveat is text: plutosvg has no text engine, so &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; elements in caller-supplied SVG render blank. Either pre-flatten to &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; (Inkscape's "Object to Path" works) or accept the limitation. Hard caps apply: 16 MB input, output dimensions capped at 4096 px and 16 Mpx, embedded &lt;code&gt;data:image/&lt;/code&gt; URIs rejected (plutosvg's loader bypasses the dim cap on those), &lt;code&gt;&amp;lt;use&amp;gt;&lt;/code&gt; elements rejected (the cycle detector doesn't count fan-out, so nested &lt;code&gt;&amp;lt;g&amp;gt;&amp;lt;use/&amp;gt;×10&lt;/code&gt; can hit billion-laughs expansion). Contract at &lt;a href="https://github.com/iliaal/fastchart/blob/main/docs/specs/svg-to-raster.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/specs/svg-to-raster.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Full SVG + PNG + JPG + WebP gallery for every chart family at &lt;a href="https://iliaal.github.io/fastchart/v1-gallery.html" rel="noopener noreferrer"&gt;iliaal.github.io/fastchart/v1-gallery.html&lt;/a&gt;. Source PHP shown above each row.&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/fastchart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repo: &lt;a href="https://github.com/iliaal/fastchart" rel="noopener noreferrer"&gt;github.com/iliaal/fastchart&lt;/a&gt;. BSD-3, PHP 8.3+, NTS or ZTS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 0.2.0 got right
&lt;/h2&gt;

&lt;p&gt;The 0.2.0 framing held: PHP doesn't need a Node sidecar for charts. The breadth held: 19 chart types covering Cartesian, financial, non-Cartesian, and specialised shapes is roughly the surface most dashboards need. The fluent OO API held. What didn't hold was libgd underneath it. libgd is fine for what libgd is; it isn't a 2026 chart rasterizer. A few releases later, fastchart isn't built on top of libgd anymore, and the output reads accordingly.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>charts</category>
    </item>
    <item>
      <title>fastjson 0.3.0: A Faster Drop-In ext/json for PHP, Backed by yyjson</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Wed, 20 May 2026 11:35:45 +0000</pubDate>
      <link>https://dev.to/iliaa/fastjson-030-a-faster-drop-in-extjson-for-php-backed-by-yyjson-5g23</link>
      <guid>https://dev.to/iliaa/fastjson-030-a-faster-drop-in-extjson-for-php-backed-by-yyjson-5g23</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$data&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&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="nv"&gt;$ok&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;json_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fastjson_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$data&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;$input&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="nv"&gt;$ok&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fastjson_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the migration. Search-and-replace &lt;code&gt;json_*&lt;/code&gt; for &lt;code&gt;fastjson_*&lt;/code&gt;. JSON flags, error constants, and last-error semantics carry across byte-for-byte. The two extensions sit next to each other in the same process; adoption is per call site, not per repo.&lt;/p&gt;

&lt;p&gt;On the simdjson_php canonical 14.8 MB corpus, that swap buys 6.06× encode throughput, 2.66× decode, and 5.10× validate against &lt;code&gt;ext/json&lt;/code&gt; on the same PHP 8.6.0-dev build. The repo is at &lt;a href="https://github.com/iliaal/fastjson" rel="noopener noreferrer"&gt;github.com/iliaal/fastjson&lt;/a&gt;. 0.3.0 shipped yesterday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why drop in faster JSON for PHP
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ext/json&lt;/code&gt; is fine. It's correct, well-maintained, and tracks the spec. On low-traffic endpoints it isn't on anyone's profiler. The cost shows up at scale: any application that calls &lt;code&gt;json_encode&lt;/code&gt; and &lt;code&gt;json_decode&lt;/code&gt; on every request path eventually finds JSON serialization sitting at the top of a flame graph. API gateways feel it first, then log processors and microservice fan-out paths.&lt;/p&gt;

&lt;p&gt;Before fastjson the practical options were two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stay on &lt;code&gt;ext/json&lt;/code&gt;. Eat the CPU cost.&lt;/li&gt;
&lt;li&gt;Reach for &lt;a href="https://github.com/crazyxman/simdjson_php" rel="noopener noreferrer"&gt;simdjson_php&lt;/a&gt;. It's fast but decode-only and not API-compatible; every call site has to be rewritten around its result shape.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;fastjson is option three. It's a native PHP extension that wraps &lt;a href="https://github.com/ibireme/yyjson" rel="noopener noreferrer"&gt;yyjson&lt;/a&gt; 0.12.0 (MIT, ~6K LOC of focused C) behind a namespaced API that mirrors &lt;code&gt;ext/json&lt;/code&gt;'s contract. PHP 8.3 minimum; 8.4 and 8.5 supported; coexists with &lt;code&gt;ext/json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "drop-in" actually means here
&lt;/h2&gt;

&lt;p&gt;The risk with any "drop-in" claim is that it covers 90% of cases and silently changes behavior on the 10% that matter. So this section is what fastjson does and what it doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Function signatures track &lt;code&gt;ext/json&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;fastjson_encode($value, int $flags = 0, int $depth = 512)&lt;/code&gt;. Same positional shape. Same defaults. Same return values on success, &lt;code&gt;false&lt;/code&gt; on failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;JSON_*&lt;/code&gt; flags match byte-for-byte.&lt;/strong&gt; &lt;code&gt;JSON_UNESCAPED_SLASHES&lt;/code&gt;, &lt;code&gt;JSON_UNESCAPED_UNICODE&lt;/code&gt;, &lt;code&gt;JSON_PRETTY_PRINT&lt;/code&gt;, the &lt;code&gt;JSON_HEX_*&lt;/code&gt; family, &lt;code&gt;JSON_THROW_ON_ERROR&lt;/code&gt;, &lt;code&gt;JSON_INVALID_UTF8_IGNORE&lt;/code&gt;, &lt;code&gt;JSON_INVALID_UTF8_SUBSTITUTE&lt;/code&gt;. The integer constants are intentionally identical so user code can pass the same flag value into either function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;JSON_ERROR_*&lt;/code&gt; codes match byte-for-byte, messages don't.&lt;/strong&gt; &lt;code&gt;fastjson_last_error()&lt;/code&gt; returns the same &lt;code&gt;JSON_ERROR_*&lt;/code&gt; int as &lt;code&gt;json_last_error()&lt;/code&gt; for the same failure class, so code branching on error codes works without modification. &lt;code&gt;fastjson_last_error_msg()&lt;/code&gt; returns yyjson's parser message (e.g., &lt;code&gt;"unexpected character"&lt;/code&gt;), not ext/json's. Application code that pattern-matches on the message string needs updating; code that branches on the code does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coexistence, not replacement.&lt;/strong&gt; Both extensions load. Migrate the call sites where JSON is on the hot path; leave the rest on &lt;code&gt;ext/json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;53 phpt tests rewritten from &lt;code&gt;php-src/ext/json/tests/*.phpt&lt;/code&gt;&lt;/strong&gt; run alongside fastjson's own suite. The rest of the upstream suite is categorized in &lt;code&gt;tests/upstream-json/.skiplist&lt;/code&gt; with the reason each test was deferred (most are tests of &lt;code&gt;ext/json&lt;/code&gt; internals that don't translate, a few hit known divergences).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documented divergences.&lt;/strong&gt; Large/scientific doubles emit &lt;code&gt;100000000000000000.0&lt;/code&gt; where &lt;code&gt;ext/json&lt;/code&gt; emits &lt;code&gt;1.0e+17&lt;/code&gt; in some ranges. U+2028 / U+2029 line separators emit as ordinary code points; &lt;code&gt;ext/json&lt;/code&gt; always escapes them for JSONP safety. Both divergences are in the skiplist with rationale.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Full simdjson_php canonical corpus: 14.8 MB across 15 files, the same set the simdjson PHP binding has been benchmarked against for years. Hardware: i9-13950HX. PHP 8.6.0-dev, release build (&lt;code&gt;-O2&lt;/code&gt;). fastjson built &lt;code&gt;-O2&lt;/code&gt; against the same PHP. yyjson 0.12.0 with three local patches. Numbers in throughput, MB/sec:&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;fastjson&lt;/th&gt;
&lt;th&gt;ext/json&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Decode (stdClass)&lt;/td&gt;
&lt;td&gt;602 MB/s&lt;/td&gt;
&lt;td&gt;227 MB/s&lt;/td&gt;
&lt;td&gt;2.66×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode (assoc array)&lt;/td&gt;
&lt;td&gt;628 MB/s&lt;/td&gt;
&lt;td&gt;237 MB/s&lt;/td&gt;
&lt;td&gt;2.65×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encode&lt;/td&gt;
&lt;td&gt;1,092 MB/s&lt;/td&gt;
&lt;td&gt;180 MB/s&lt;/td&gt;
&lt;td&gt;6.06×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validate&lt;/td&gt;
&lt;td&gt;1,352 MB/s&lt;/td&gt;
&lt;td&gt;265 MB/s&lt;/td&gt;
&lt;td&gt;5.10×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Visual side-by-side, including &lt;code&gt;ext/json&lt;/code&gt; with Nora Dossche's open SIMD encode work (&lt;a href="https://github.com/php/php-src/pull/17734" rel="noopener noreferrer"&gt;php-src#17734&lt;/a&gt;) and simdjson_php on the same PHP 8.6.0-dev build, lives at &lt;a href="https://iliaal.github.io/fastjson/baseline.html" rel="noopener noreferrer"&gt;https://iliaal.github.io/fastjson/baseline.html&lt;/a&gt;. Reproduce locally with &lt;code&gt;bench/run.php&lt;/code&gt; against any PHP install.&lt;/p&gt;

&lt;p&gt;The encode speedup is the largest gap because the PHP-native encoder has the most room to give back. ndossche's php-src#17734 patch closes a meaningful chunk of that gap inside &lt;code&gt;ext/json&lt;/code&gt; itself using SIMD on string encoding. fastjson and that PR attack the same problem from different angles, and an application can benefit from both once #17734 lands upstream (fastjson re-baselines automatically; the visual page already shows both).&lt;/p&gt;

&lt;h2&gt;
  
  
  How the encoder gets to 6×
&lt;/h2&gt;

&lt;p&gt;The encoder is one-stage. A zval walks straight into a &lt;code&gt;smart_str&lt;/code&gt; buffer via yyjson's writer primitives. There's no intermediate DOM, no two-pass build-then-serialize, no temporary string allocations for the common-case scalars. Each PHP value type maps to one or two yyjson calls; arrays and objects walk recursively into the same path.&lt;/p&gt;

&lt;p&gt;A few less-obvious pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Custom allocator wired through Zend.&lt;/strong&gt; yyjson's allocator hooks route every alloc/realloc/free through PHP's &lt;code&gt;emalloc&lt;/code&gt; family. JSON workloads count against &lt;code&gt;memory_limit&lt;/code&gt; and against per-request memory accounting; oversized inputs bail out the same way any other PHP allocation does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct &lt;code&gt;smart_str&lt;/code&gt; integration.&lt;/strong&gt; No &lt;code&gt;RETURN_STRING(estrdup(buf))&lt;/code&gt; after a separate allocation. The yyjson writer writes into the &lt;code&gt;smart_str.s&lt;/code&gt; backing store, which becomes the return zend_string directly. One allocation per encode call in the common case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HEX_*&lt;/code&gt; flag scan-first.&lt;/strong&gt; The flags &lt;code&gt;JSON_HEX_TAG&lt;/code&gt;/&lt;code&gt;HEX_AMP&lt;/code&gt;/&lt;code&gt;HEX_APOS&lt;/code&gt;/&lt;code&gt;HEX_QUOT&lt;/code&gt; rewrite specific characters into hex escapes. fastjson scans the string once for any candidate character; if none are present, the rewrite path is skipped entirely and the string is encoded directly. Defensive callers that pass &lt;code&gt;HEX_*&lt;/code&gt; flags as a precaution on payloads that don't actually contain the substituted characters don't pay the rewrite cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integer-valued-double shortcut.&lt;/strong&gt; When a &lt;code&gt;double&lt;/code&gt; round-trips losslessly through &lt;code&gt;int64&lt;/code&gt;, fastjson emits it as an integer without going through &lt;code&gt;php_gcvt&lt;/code&gt; or yyjson's REAL writer. A cheap range check fires before &lt;code&gt;floor()&lt;/code&gt;, so number-heavy arrays of non-integer doubles don't pay libm per element.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decode path's gain has different sources: yyjson itself, which parses with less branching and tighter memory locality than &lt;code&gt;ext/json&lt;/code&gt;'s parser, and a local yyjson patch (&lt;code&gt;YYJSON_READ_VALIDATE_ONLY&lt;/code&gt;) that turns the read path into a fast validate-only mode without materializing values.&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory tradeoff
&lt;/h2&gt;

&lt;p&gt;Worth surfacing before anyone hits it in production. Decode and validate hold the yyjson document object in memory alongside the PHP-side result, because yyjson's value graph is built first, then traversed to produce zvals. Peak heap on decode is roughly 1.7× what &lt;code&gt;ext/json&lt;/code&gt; peaks at on the same input. Encode is one-stage and peaks at ~1.06× of &lt;code&gt;ext/json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Validate is the loudest: peak heap is ~101× &lt;code&gt;ext/json&lt;/code&gt;'s streaming validator (which sits at a constant ~80 bytes since it doesn't materialize anything). The headline number sounds extreme, but the absolute footprint is bounded by yyjson's read path, and it's already 2.7× lower than yyjson's stock read path thanks to a vendored validate-only patch (&lt;code&gt;YYJSON_READ_VALIDATE_ONLY&lt;/code&gt;) that skips the value-graph build.&lt;/p&gt;

&lt;p&gt;For most callers, the speedup wins. If the application is validate-heavy on giant inputs under tight &lt;code&gt;memory_limit&lt;/code&gt;, the memory profile is a real consideration. The right move there is to leave validate-on-huge-inputs on &lt;code&gt;ext/json&lt;/code&gt; and migrate the encode and decode paths. That's exactly what coexistence buys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compat harness
&lt;/h2&gt;

&lt;p&gt;53 rewritten phpt tests from &lt;code&gt;php-src/ext/json/tests/*.phpt&lt;/code&gt; run alongside fastjson's native suite. They cover the common decode/encode/validate paths, the flag combinations, and the documented error conditions.&lt;/p&gt;

&lt;p&gt;The two remaining intentional divergences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Large/scientific doubles.&lt;/strong&gt; Outside the integer-valued-double shortcut range, fastjson emits yyjson's real-number format. &lt;code&gt;ext/json&lt;/code&gt; uses &lt;code&gt;php_gcvt&lt;/code&gt; and switches to scientific notation earlier. The disagreement window narrowed in 0.3.0; what's left is the genuinely-fractional case where yyjson and &lt;code&gt;php_gcvt&lt;/code&gt; produce different decimal representations of the same IEEE 754 double.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;U+2028 / U+2029 line separators.&lt;/strong&gt; &lt;code&gt;ext/json&lt;/code&gt; always escapes these for JSONP safety. yyjson treats them as ordinary code points. fastjson follows yyjson's behavior. If JSONP is in the deployment path, set &lt;code&gt;JSON_UNESCAPED_UNICODE&lt;/code&gt; off in &lt;code&gt;ext/json&lt;/code&gt; and stay on &lt;code&gt;ext/json&lt;/code&gt; for that endpoint, or wrap fastjson output through a JSONP-safe post-step.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Upstream collaboration with yyjson
&lt;/h2&gt;

&lt;p&gt;fastjson vendors yyjson 0.12.0 with three local patches. Full details are in &lt;code&gt;vendor/yyjson/PATCHES.md&lt;/code&gt;; the short version of each, and what happened when each was proposed upstream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lowercase hex digits in &lt;code&gt;\uXXXX&lt;/code&gt; escape table.&lt;/strong&gt; yyjson emits uppercase; &lt;code&gt;ext/json&lt;/code&gt; emits lowercase. RFC 8259 §7 allows either, but byte-parity with &lt;code&gt;ext/json&lt;/code&gt; is the project's compat goal. Proposed as &lt;a href="https://github.com/ibireme/yyjson/pull/264" rel="noopener noreferrer"&gt;yyjson#264&lt;/a&gt; (&lt;code&gt;YYJSON_WRITE_LOWERCASE_HEX&lt;/code&gt; flag) and &lt;strong&gt;accepted&lt;/strong&gt; upstream on 2026-05-11. fastjson will drop this patch once the vendored sources advance past yyjson 0.12.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;YYJSON_READ_VALIDATE_ONLY&lt;/code&gt;, no-tree validate mode.&lt;/strong&gt; Forks yyjson's parser entry point to skip the value-graph build entirely; peak memory drops 2.7× on the validate corpus. Not yet proposed upstream; the API surface needs a round of review before it's ready to submit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public &lt;code&gt;yyjson_write_string_to_buf()&lt;/code&gt; wrapper.&lt;/strong&gt; Exposes yyjson's internal direct-write primitive so fastjson's one-stage encoder can compose at the buffer level. Proposed as &lt;a href="https://github.com/ibireme/yyjson/pull/266" rel="noopener noreferrer"&gt;yyjson#266&lt;/a&gt; and &lt;strong&gt;closed&lt;/strong&gt; upstream; the maintainer preferred to keep that surface private. The wrapper stays vendored locally; fastjson lives with it indefinitely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&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/fastjson
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or build from source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;phpize
./configure
make &lt;span class="nt"&gt;-j&lt;/span&gt;
make &lt;span class="nb"&gt;test
sudo &lt;/span&gt;make &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP 8.3 minimum. No external library dependencies; yyjson is vendored in &lt;code&gt;src/yyjson/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-character migration
&lt;/h2&gt;

&lt;p&gt;fastjson stays useful as long as yyjson's design choices (one-stage encoder, validate-only fast path, allocator hooks) beat what fits into &lt;code&gt;ext/json&lt;/code&gt;'s compatibility envelope. For the call sites where JSON serialization shows up on a flame graph, the migration is four characters; the rest of the codebase doesn't have to care.&lt;/p&gt;




&lt;p&gt;Repo: &lt;a href="https://github.com/iliaal/fastjson" rel="noopener noreferrer"&gt;https://github.com/iliaal/fastjson&lt;/a&gt;. Benchmark methodology and reproduction: &lt;code&gt;bench/run.php&lt;/code&gt;. Visual baseline: &lt;a href="https://iliaal.github.io/fastjson/baseline.html" rel="noopener noreferrer"&gt;https://iliaal.github.io/fastjson/baseline.html&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>json</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>fastchart 0.2.0: Native PHP Charts, Barcodes, and QR Codes in One Extension</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Tue, 12 May 2026 13:50:03 +0000</pubDate>
      <link>https://dev.to/iliaa/fastchart-020-native-php-charts-barcodes-and-qr-codes-in-one-extension-584f</link>
      <guid>https://dev.to/iliaa/fastchart-020-native-php-charts-barcodes-and-qr-codes-in-one-extension-584f</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FastChart\StockChart&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;setSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1200&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AAPL last 90 days'&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;setTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FastChart\Chart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;THEME_DARK&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;setOhlcv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ohlcvRows&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;setMovingAverages&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&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;setVolumePane&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setCandleStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FastChart\Chart&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STYLE_HOLLOW&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;renderToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/tmp/aapl.png'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a server-side OHLCV candlestick chart with three moving averages, a volume pane, and a hollow candle style. Roughly 68 ms on a single core at 1920×1080. No microservice, no Node sidecar, no JavaScript runtime. PHP, gd, fastchart.&lt;/p&gt;

&lt;p&gt;fastchart 0.2.0 shipped two days ago. 19 chart types behind a fluent OO API, plus a Symbol family (Code 128 barcodes and QR codes) that landed in this release. The repo is at &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;
  
  
  Why charts in PHP again
&lt;/h2&gt;

&lt;p&gt;Twenty years ago, Rasmus and I shipped the initial release of PECL/GDChart in January 2006. It wrapped Bruce Verderaime's gdchart C library from &lt;code&gt;users.fred.net/brv/chart/&lt;/code&gt;. The PECL page is still up at &lt;a href="https://pecl.php.net/package/GDChart" rel="noopener noreferrer"&gt;https://pecl.php.net/package/GDChart&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Both projects died. Verderaime's gdchart library hasn't moved since the mid-2000s; the homepage at &lt;code&gt;users.fred.net/brv/chart/&lt;/code&gt; has been a tombstone for almost as long. The PECL extension followed. One release, then nothing.&lt;/p&gt;

&lt;p&gt;The PHP charting ecosystem since then has been thin. JpGraph kept moving but active development went to the commercial fork; the OSS branch is calcifying. pChart is unmaintained. Many PHP teams that need server-side charts in 2026 either reach for a Node or Python microservice (Chart.js via Puppeteer, matplotlib via subprocess) or accept that "server-side rendering" means "render in the browser and screenshot it." Neither is good.&lt;/p&gt;

&lt;p&gt;Between PECL/GDChart and now, I've kept needing charts and graphs in PHP. Mostly charts, occasionally barcodes and QR codes. Each new project I'd reach for the easy options first: command-line tools wrapped through &lt;code&gt;shell_exec&lt;/code&gt;, pure-PHP libraries when they were fast enough, more recently chart.js renders shipped through a Puppeteer or headless-Chrome wrapper. Those work until they don't. When scale showed up the wrapper started dominating request latency, and I'd write a little PHP extension that handled the specific case causing pain.&lt;/p&gt;

&lt;p&gt;Roughly six of those extensions accumulated over the years. Each did one thing. One generated QR codes for serial numbers on physical labels. One drew two chart types for an internal reporting dashboard. One was just OHLC candlesticks with moving averages. None of them shipped. They lived in private repos, solved the immediate problem, and never got cleaned up enough to release.&lt;/p&gt;

&lt;p&gt;fastchart is the attempt to close that gap publicly. One extension, the breadth of shapes I've kept needing, a fluent OO API, BSD-licensed. StockChart got the deepest treatment in this release (seven candle styles, the full indicator stack) because the most recent of the six private extensions was the trading-chart one and it carried over almost verbatim. The other eighteen chart types and the Symbol family came from cleaning up and merging the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in 0.2.0
&lt;/h2&gt;

&lt;p&gt;Nineteen chart classes plus a two-class Symbol family for barcodes and QR codes. Five output formats. Two render paths. The full surface is 105 public methods covered by 97 tests. PHP 8.3+ minimum, NTS or ZTS.&lt;/p&gt;

&lt;p&gt;The chart classes split into four shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cartesian.&lt;/strong&gt; Line, Area, Bar (vertical, horizontal, stacked, grouped, floating, layered), Scatter, Bubble.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Financial.&lt;/strong&gt; A deep &lt;code&gt;StockChart&lt;/code&gt; class: seven candle styles (CANDLE, BAR, DIAMOND, I_CAP, HOLLOW, VOLUME, VECTOR), SMA/EMA/WMA overlays, a volume pane, and indicator panes (RSI, MACD, Bollinger Bands, Parabolic SAR, Stochastic, OBV).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-Cartesian.&lt;/strong&gt; Radar, Polar, Surface, Contour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specialised.&lt;/strong&gt; Pie (with donut hole and leader lines), Gauge, LinearMeter, Gantt (with dependencies and milestones), BoxPlot, Treemap, Funnel, Waterfall, Heatmap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Symbol family added in 0.2.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code 128.&lt;/strong&gt; ISO/IEC 15417. Auto-switches between A/B/C subsets to minimize encoded length. Mod-103 checksum appended automatically. Optional human-readable payload rendered below the bars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR Code.&lt;/strong&gt; ISO/IEC 18004. Four error-correction levels (ECC_L/M/Q/H), versions 1 through 40. Encoder is the vendored nayuki/QR-Code-generator C library under MIT.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Output formats are the standard gd set plus the modern ones: PNG, JPEG, WebP, AVIF, GIF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why barcodes and QR codes in a chart library
&lt;/h2&gt;

&lt;p&gt;Because they all render to a gd canvas, they all serve the same use case (server-side image generation in PHP), and they share the same painful problem: the existing PHP-native options are mostly dead or third-party packages with their own dependency stacks.&lt;/p&gt;

&lt;p&gt;The unifying thread is gd, not "chart." If you're rendering a dashboard tile, a sales report PDF, an invoice with a scannable serial number, or a shipping label with a barcode, you're producing an image on the server. PHP has had &lt;code&gt;ext/gd&lt;/code&gt; since 4.0.0. fastchart treats &lt;code&gt;ext/gd&lt;/code&gt; as the substrate and adds higher-level shapes on top. The Symbol classes don't claim to be charts; they live in their own family parallel to &lt;code&gt;Chart&lt;/code&gt;, with shared base setters and the same render-format set.&lt;/p&gt;

&lt;p&gt;The public options before fastchart were mostly pure-PHP libraries shipping their own glyph tables and rasterizers, or wrappers around command-line tools like &lt;code&gt;qrencode&lt;/code&gt;. Both work. Both add a dependency surface that a &lt;code&gt;pie install&lt;/code&gt; doesn't cover. fastchart pulls QR and Code 128 into the same &lt;code&gt;.so&lt;/code&gt; as the charts. One install, one dependency (gd), one fluent API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compose path
&lt;/h2&gt;

&lt;p&gt;Charts let you hand fastchart a &lt;code&gt;\GdImage&lt;/code&gt; canvas you own. It draws into your canvas and returns the same canvas back. Symbols don't accept a caller-owned canvas (a barcode's quiet zone makes compositing inside an existing image ambiguous); they render fresh and you &lt;code&gt;imagecreatefromstring()&lt;/code&gt; to composite afterwards.&lt;/p&gt;

&lt;p&gt;The composability is the differentiator from JpGraph, pChart, and most JS-bridged solutions. They own the canvas. You get a finished PNG file back and composite at the file or page level, never at the pixel level. fastchart's two-path design covers both:&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;// Path 1: "give me a file."&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;FastChart\LineChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;800&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setSeries&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$values&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;renderToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/tmp/line.png'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Path 2: "draw onto my canvas." Two charts side by side on the same image.&lt;/span&gt;
&lt;span class="nv"&gt;$canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;imagecreatetruecolor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1600&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FastChart\LineChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1600&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Daily active users'&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;setSeries&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$values&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;setPlotRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;820&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;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$canvas&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;FastChart\BarChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1600&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Quarterly revenue'&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;setSeries&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="s1"&gt;'data'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$bars&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;setPlotRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;880&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1520&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;820&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;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$canvas&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Stamp something gd-native on top.&lt;/span&gt;
&lt;span class="nv"&gt;$font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;imagettftext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&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;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$white&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$font&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;imagepng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/tmp/dashboard.png'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A four-tile dashboard, a chart embedded in a PDF page, a chart and its legend baked together on a sprite, same primitives, no separate render passes, no temp files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;Every chart type renders under 100 ms at 1920×1080 on a single core. The lighter types break 100 renders per second per core at dashboard-tile size (640×480). Numbers from my workstation (Intel i9-13950HX, PHP 8.4 NTS), default font and DPI.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chart&lt;/th&gt;
&lt;th&gt;640×480 ms&lt;/th&gt;
&lt;th&gt;1920×1080 ms&lt;/th&gt;
&lt;th&gt;1080p ops/sec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AreaChart&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BarChart&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BoxPlot&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BubbleChart&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ContourChart&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Funnel&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GanttChart&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GaugeChart&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heatmap&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LineChart&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinearMeter&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PieChart&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PolarChart&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RadarChart&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScatterChart&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StockChart&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SurfaceChart&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Treemap&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Waterfall&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are not "we render faster than Chart.js running in Puppeteer" numbers. Headless-browser rendering is slow for completely different reasons (process startup, JS runtime, layout, paint). The honest framing is that fastchart removes the JS-render path entirely from server-side image generation. The benchmark is a sanity check that the C path is fast enough to stop reaching for a sidecar, not a marketing claim.&lt;/p&gt;

&lt;p&gt;Bench source is at &lt;code&gt;docs/bench/bench.php&lt;/code&gt;. Reproduce locally with &lt;code&gt;php -d extension=gd -d extension=./modules/fastchart.so docs/bench/bench.php&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&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/fastchart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or build from source against the PHP install you want to extend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;phpize
./configure &lt;span class="nt"&gt;--enable-fastchart&lt;/span&gt;
make &lt;span class="nt"&gt;-j&lt;/span&gt;
make &lt;span class="nb"&gt;test
sudo &lt;/span&gt;make &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP 8.3 or newer, plus &lt;code&gt;ext/gd&lt;/code&gt;. fastchart declares &lt;code&gt;ZEND_MOD_REQUIRED("gd")&lt;/code&gt; so the engine orders MINIT correctly regardless of &lt;code&gt;php.ini&lt;/code&gt; / conf.d / &lt;code&gt;-d extension=&lt;/code&gt; load order. (Earlier 0.1.0 didn't, and &lt;code&gt;docker-php-ext-enable&lt;/code&gt;'s alphabetical &lt;code&gt;conf.d&lt;/code&gt; ordering caused fastchart to load before gd. That was the only thing 0.1.1 fixed.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty years later
&lt;/h2&gt;

&lt;p&gt;The 2006 ext-gdchart was a hundred lines of glue around roughly 1,000 lines of upstream library. Single chart family, single output format, a tiny config surface. It worked. It died the moment its upstream did.&lt;/p&gt;

&lt;p&gt;The bet with fastchart is the opposite: own enough of the substrate that the project's lifespan isn't bound to anything external besides gd, which has been in PHP since 4.0.0. Nineteen chart types, two symbol types, the whole stack lives in this repo. No third-party chart library to outlast, no microservice to keep alive, no JS toolchain to drag along.&lt;/p&gt;

&lt;p&gt;Twenty years between PHP charting extensions is long enough.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>charts</category>
    </item>
    <item>
      <title>mdparser: a native CommonMark + GFM parser for PHP</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Wed, 06 May 2026 14:33:28 +0000</pubDate>
      <link>https://dev.to/iliaa/mdparser-a-native-commonmark-gfm-parser-for-php-eod</link>
      <guid>https://dev.to/iliaa/mdparser-a-native-commonmark-gfm-parser-for-php-eod</guid>
      <description>&lt;p&gt;Several of my projects do heavy markdown parsing. Comment rendering, documentation pipelines, content management. The volume keeps growing, and I've been hitting the point where pure-PHP parsers (Parsedown, league/commonmark, cebe/markdown, michelf) just can't keep up. They're solid libraries, but parsing thousands of documents per request or chewing through 200 KB files in interpreted PHP is slow no matter how well the code is written.&lt;/p&gt;

&lt;p&gt;I wanted something 10x+ faster that could serve as a drop-in replacement for the common cases. The result is &lt;a href="https://github.com/iliaal/mdparser" rel="noopener noreferrer"&gt;mdparser&lt;/a&gt;, a native C extension that wraps cmark-gfm (GitHub's CommonMark parser) and exposes it through a clean PHP 8.3+ OO API. I'm releasing it today.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;mdparser vendors a copy of cmark-gfm 0.29.0.gfm.13 directly into the extension's shared object. No external library to link against, no cmake, no runtime dependencies. The entire cmark-gfm codebase compiles alongside the PHP wrapper into a single &lt;code&gt;.so&lt;/code&gt; (or &lt;code&gt;.dll&lt;/code&gt; on Windows). Four cherry-picked commits from cmark upstream close the 0.29-to-0.31 spec gap, giving full CommonMark 0.31 conformance: 652 out of 652 spec examples pass.&lt;/p&gt;

&lt;p&gt;The PHP API is intentionally small. Two classes, one exception:&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;MdParser\Parser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;MdParser\Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Defaults: safe mode on, GFM extensions on.&lt;/span&gt;
&lt;span class="nv"&gt;$parser&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;Parser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;echo&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;toHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'# Hello'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Or the static shorthand:&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nc"&gt;Parser&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'# Hello'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Custom options via named arguments:&lt;/span&gt;
&lt;span class="nv"&gt;$parser&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;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;smart&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;footnotes&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;sourcepos&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="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Three output formats:&lt;/span&gt;
&lt;span class="nv"&gt;$html&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;toHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$markdown&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$xml&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;toXml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$markdown&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$ast&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;toAst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$markdown&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// nested PHP arrays&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Options&lt;/code&gt; is &lt;code&gt;final readonly&lt;/code&gt; with 17 boolean fields. The &lt;code&gt;Parser&lt;/code&gt; constructor translates those bools into cmark's internal bitmask once, so every subsequent parse call is pure cmark work with zero per-call overhead. Static factory presets (&lt;code&gt;Options::strict()&lt;/code&gt;, &lt;code&gt;Options::github()&lt;/code&gt;, &lt;code&gt;Options::permissive()&lt;/code&gt;) cover common deployment patterns.&lt;/p&gt;

&lt;p&gt;If you're migrating from Parsedown's &lt;code&gt;line()&lt;/code&gt; or cebe/markdown's &lt;code&gt;parseParagraph()&lt;/code&gt;, there's &lt;code&gt;toInlineHtml()&lt;/code&gt;: inline-only HTML without the wrapping &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; tags. Useful for chat messages, table cells, and short user-facing strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;This was the primary motivation. Measured on PHP 8.4 with each parser in its default configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parser&lt;/th&gt;
&lt;th&gt;Small (200 B)&lt;/th&gt;
&lt;th&gt;Medium (1.8 KB)&lt;/th&gt;
&lt;th&gt;Large (200 KB)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mdparser&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30,447 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5,697 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;105 ops/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parsedown&lt;/td&gt;
&lt;td&gt;1,651 ops/s (18x slower)&lt;/td&gt;
&lt;td&gt;325 ops/s (17x)&lt;/td&gt;
&lt;td&gt;6 ops/s (17x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cebe/markdown (GFM)&lt;/td&gt;
&lt;td&gt;1,350 ops/s (22x)&lt;/td&gt;
&lt;td&gt;374 ops/s (15x)&lt;/td&gt;
&lt;td&gt;6 ops/s (16x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;michelf (Extra)&lt;/td&gt;
&lt;td&gt;1,006 ops/s (30x)&lt;/td&gt;
&lt;td&gt;209 ops/s (27x)&lt;/td&gt;
&lt;td&gt;5 ops/s (19x)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;15-30x faster, from 200-byte chat messages to 200 KB documents. Your absolute numbers will differ by hardware, but the ratios hold. mdparser processes roughly 100 full CommonMark-spec-sized documents per second on a single core. The pure-PHP parsers manage 5-6.&lt;/p&gt;

&lt;p&gt;The benchmark uses &lt;code&gt;hrtime(true)&lt;/code&gt; around each parse call, 200 iterations with warm-up, trimmed mean to filter GC pauses. Reproducible scripts are in the &lt;a href="https://github.com/iliaal/mdparser/tree/master/bench" rel="noopener noreferrer"&gt;bench/ directory&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature comparison
&lt;/h2&gt;

&lt;p&gt;mdparser covers CommonMark core plus all five GFM extensions. Here's how it stacks up against the pure-PHP alternatives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;mdparser&lt;/th&gt;
&lt;th&gt;Parsedown&lt;/th&gt;
&lt;th&gt;league/cm&lt;/th&gt;
&lt;th&gt;cebe GFM&lt;/th&gt;
&lt;th&gt;michelf Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CommonMark core&lt;/td&gt;
&lt;td&gt;full&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;full&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GFM tables&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;via ext&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;via Extra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strikethrough&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;via ext&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task lists&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;via ext&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autolinks&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;via ext&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag filter&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;via ext&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smart punctuation&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;via ext&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Footnotes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;Extra&lt;/td&gt;
&lt;td&gt;via ext&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sourcepos&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XML output&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AST output&lt;/td&gt;
&lt;td&gt;yes (arrays)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes (objects)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What mdparser doesn't do
&lt;/h2&gt;

&lt;p&gt;mdparser is scoped to what cmark-gfm supports: CommonMark core plus five GFM extensions. It doesn't cover definition lists, abbreviations, attribute syntax, heading permalinks, table of contents, YAML front matter, mentions, LaTeX math, emoji shortcodes, or custom containers. If you need those, &lt;a href="https://github.com/thephpleague/commonmark" rel="noopener noreferrer"&gt;league/commonmark&lt;/a&gt; is the right choice. It's the most featureful pure-PHP option and actively maintained. Speed doesn't help if the feature you need isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility
&lt;/h2&gt;

&lt;p&gt;mdparser builds and tests on PHP 8.3, 8.4, and 8.5 across Linux (x86_64), macOS (arm64/x86_64), and Windows (x86/x64, both TS and NTS). CI runs on all three platforms, with an ASAN job on Linux to catch memory issues. Pre-built Windows DLLs ship with each GitHub release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&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/mdparser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PIE handles the download, phpize, configure, make, and install. On a minimal PHP image you'll need &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;bison&lt;/code&gt;, and &lt;code&gt;libtool-bin&lt;/code&gt; as build dependencies.&lt;/p&gt;

&lt;p&gt;From source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/iliaal/mdparser.git
&lt;span class="nb"&gt;cd &lt;/span&gt;mdparser
phpize &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./configure &lt;span class="nt"&gt;--enable-mdparser&lt;/span&gt;
make &lt;span class="nt"&gt;-j&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;make &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/iliaal/mdparser" rel="noopener noreferrer"&gt;github.com/iliaal/mdparser&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Packagist: &lt;a href="https://packagist.org/packages/iliaal/mdparser" rel="noopener noreferrer"&gt;packagist.org/packages/iliaal/mdparser&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>markdown</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
