<?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.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>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>
    <item>
      <title>php_excel 2.0: The C Extension for Excel That PHP Should Have Had All Along</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Mon, 04 May 2026 18:28:22 +0000</pubDate>
      <link>https://dev.to/iliaa/phpexcel-20-the-c-extension-for-excel-that-php-should-have-had-all-along-5gfi</link>
      <guid>https://dev.to/iliaa/phpexcel-20-the-c-extension-for-excel-that-php-should-have-had-all-along-5gfi</guid>
      <description>&lt;p&gt;PHP processes more Excel files than any language except maybe Python. Payroll exports, inventory imports, financial reports, data migrations. If your business runs on spreadsheets (and it does), your PHP app touches them constantly.&lt;/p&gt;

&lt;p&gt;The standard approach is PhpSpreadsheet: a pure-PHP library that parses XML, builds an in-memory object graph, and promptly devours your server's RAM. It works fine for small files. It falls apart the moment someone uploads a 50,000-row export from SAP. (Don't ask me how I know. 😢)&lt;/p&gt;

&lt;p&gt;php_excel takes a different path. It's a PHP extension that wraps &lt;a href="https://www.libxl.com/" rel="noopener noreferrer"&gt;LibXL&lt;/a&gt;, a commercial C/C++ library purpose-built for reading and writing Excel files. Unlike most alternatives, LibXL handles both modern xlsx (Office 2007+) and the legacy xls binary format (Excel 97-2003), so you don't need separate codepaths for old and new files.&lt;/p&gt;

&lt;p&gt;Instead of parsing XML in userland PHP, every cell read and write is a single C function call. In my benchmarks it's 7-10x faster than PhpSpreadsheet, and its memory footprint stays flat while PhpSpreadsheet's climbs past a gigabyte.&lt;/p&gt;

&lt;p&gt;Version 2.0 shipped on April 5, 2026. It's the first major release in a long while, and it brings the extension fully into modern PHP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Changed in 2.0&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The last official release (1.0.2) dates back to 2016. There was an unreleased 1.0.3 with PHP 7 support contributed by community members a few years later, but it never shipped. PHP has changed a lot since then. 2.0 is a ground-up modernization:&lt;/p&gt;

&lt;p&gt;PHP 8.3 is the minimum version. Full support for 8.4, 8.5, and the development master branch. The extension builds and tests clean against all four, with zero warnings. On the LibXL side, the minimum is 4.6.0 (released over a year ago), with support up to the latest 5.1.0. I gate newer LibXL features at compile time: you get everything your installed version supports, and the extension still builds clean against 4.6.0.&lt;/p&gt;

&lt;p&gt;2.0 adds six new classes to the existing six:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ExcelRichString&lt;/strong&gt;: mixed-font text in a single cell (bold the header, italicize the footnote)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ExcelFormControl&lt;/strong&gt;: checkboxes, dropdowns, spinners, buttons, and other form widgets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ExcelConditionalFormat&lt;/strong&gt; / &lt;strong&gt;ExcelConditionalFormatting&lt;/strong&gt;: conditional formatting rules and their application to cell ranges&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ExcelCoreProperties&lt;/strong&gt;: workbook metadata (title, author, creation dates, categories)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ExcelTable&lt;/strong&gt;: structured table support for xlsx&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I added methods across every existing class. ExcelBook now handles rich strings, conditional formats, VBA removal, DPI awareness, and picture-as-link support. ExcelSheet got pixel-based column/row sizing, tab colors, active cell management, selection ranges, form controls, data validation, and structured tables.&lt;/p&gt;

&lt;p&gt;Every parameter and return type now has proper arginfo. That's 399 typed parameters and 277 typed return values, which means IDE autocompletion and static analysis tools actually work with the extension.&lt;/p&gt;

&lt;p&gt;I also cleaned up the internals. Constructors now throw exceptions on error instead of silently returning false. I fixed several use-after-free bugs, null pointer dereferences, and memory leaks, the kind of issues that only surface under heavy load or with AddressSanitizer enabled. Serialization is disabled on all classes (serializing a file handle was never going to end well). The full list is in the &lt;a href="https://github.com/iliaal/php_excel/blob/master/ChangeLog" rel="noopener noreferrer"&gt;ChangeLog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a C Extension?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PHP's process-per-request model means every request starts from scratch. When PhpSpreadsheet opens a 100,000-row file, it parses the underlying XML with SimpleXML (DOM-based, so the entire XML tree lives in memory), then builds a Cell object for every cell with coordinate indexes, style references, and type metadata. In my benchmarks, PhpSpreadsheet 5.5 used about 1,066 MB to load 2 million cells, roughly 0.5 KB per cell for the object graph alone. That's already over a gigabyte for a single 100,000-row sheet. Add formatting, and it climbs further.&lt;/p&gt;

&lt;p&gt;LibXL does the same work in optimized C/C++. The XML parsing, ZIP decompression, and cell storage all happen in native code. The extension itself is a thin translation layer: it takes PHP zvals, converts them to C types, calls the LibXL function, and converts the result back. The overhead per cell is a few hundred nanoseconds.&lt;/p&gt;

&lt;p&gt;LibXL still uses real memory on the C heap for its internal workbook structures, so this isn't a free lunch. But it uses that memory more efficiently than PhpSpreadsheet's PHP object graph, and it's close to an order of magnitude faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benchmarks&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I ran these on PHP 8.4.19 (release build, NTS) against PhpSpreadsheet 5.5.0. Each test writes or reads a mix of integers, floats, and strings across 20 columns. Same data, same machine, measured with &lt;code&gt;hrtime()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the php_excel write test:&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;$book&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;ExcelBook&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="kc"&gt;null&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="c1"&gt;// xlsx mode&lt;/span&gt;
&lt;span class="nv"&gt;$sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Bench'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&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;$c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$cols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&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;$c&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;write&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="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$cols&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// integer&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;write&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="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Row&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_Col&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_Text"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// string&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&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;write&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="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// float&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;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$outFile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the PhpSpreadsheet equivalent:&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;$spreadsheet&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;Spreadsheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$spreadsheet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getActiveSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$cols&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&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;$c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;setCellValue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$c&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="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$cols&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;setCellValue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$c&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="s2"&gt;"Row&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_Col&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_Text"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&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;setCellValue&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$c&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="nv"&gt;$r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&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="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$writer&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;Xlsx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$spreadsheet&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$writer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$outFile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Write results&lt;/strong&gt; (time / total process memory via VmPeak):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Cells&lt;/th&gt;
&lt;th&gt;php_excel&lt;/th&gt;
&lt;th&gt;PhpSpreadsheet&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;20K&lt;/td&gt;
&lt;td&gt;0.05s / 85 MB&lt;/td&gt;
&lt;td&gt;0.45s / 162 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;200K&lt;/td&gt;
&lt;td&gt;0.55s / 153 MB&lt;/td&gt;
&lt;td&gt;4.59s / 282 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;2.72s / 508 MB&lt;/td&gt;
&lt;td&gt;24.7s / 790 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;2M&lt;/td&gt;
&lt;td&gt;5.37s / 908 MB&lt;/td&gt;
&lt;td&gt;51.1s / 1,415 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All memory figures are total process RSS (VmPeak from &lt;code&gt;/proc/self/status&lt;/code&gt;), including the C heap. LibXL does use real memory for its internal structures, so php_excel isn't "free" on that front. But even counting everything, it uses 40-65% of what PhpSpreadsheet needs and finishes 9-10x faster.&lt;/p&gt;

&lt;p&gt;One practical detail: PHP's &lt;code&gt;memory_get_peak_usage()&lt;/code&gt; reports 2 MB for php_excel throughout, versus 1,254 MB for PhpSpreadsheet at 100K rows. LibXL allocates on the C heap, invisible to PHP's memory manager and &lt;code&gt;memory_limit&lt;/code&gt;. If your PHP-FPM pool has a 128 MB memory_limit, PhpSpreadsheet will OOM on a 10K-row file. php_excel won't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read results&lt;/strong&gt; (time / total process memory via VmPeak):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Cells&lt;/th&gt;
&lt;th&gt;php_excel&lt;/th&gt;
&lt;th&gt;PhpSpreadsheet&lt;/th&gt;
&lt;th&gt;OpenSpout&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;20K&lt;/td&gt;
&lt;td&gt;0.05s / 83 MB&lt;/td&gt;
&lt;td&gt;0.39s / 175 MB&lt;/td&gt;
&lt;td&gt;0.16s / 126 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;200K&lt;/td&gt;
&lt;td&gt;0.47s / 144 MB&lt;/td&gt;
&lt;td&gt;3.74s / 578 MB&lt;/td&gt;
&lt;td&gt;1.48s / 130 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;2.60s / 422 MB&lt;/td&gt;
&lt;td&gt;20.1s / 2,317 MB&lt;/td&gt;
&lt;td&gt;8.00s / 130 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;2M&lt;/td&gt;
&lt;td&gt;5.19s / 767 MB&lt;/td&gt;
&lt;td&gt;40.8s / 4,501 MB&lt;/td&gt;
&lt;td&gt;16.6s / 130 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three different profiles. php_excel is fastest but uses memory proportional to the file (LibXL loads the whole workbook). PhpSpreadsheet is slowest and its memory explodes, 4.5 GB for 100K rows. OpenSpout streams row by row, so memory stays flat at 130 MB regardless of size, but it's 3x slower than php_excel.&lt;/p&gt;

&lt;p&gt;To be fair to PhpSpreadsheet: it's the most feature-complete pure-PHP Excel library. If you don't need a C extension and your files stay under a few thousand rows, it works fine. Past that, it doesn't.&lt;/p&gt;

&lt;p&gt;OpenSpout fills a different niche. It can't do random cell access, conditional formatting, formulas, rich text, form controls, or xls format. If your workload is "read CSV-like data from xlsx," it's a good free choice. If you need actual Excel features, it won't help.&lt;/p&gt;

&lt;p&gt;php_excel gives you C library speed with the full Excel feature set. The tradeoff: LibXL requires a commercial license, and installing a C extension takes more effort than &lt;code&gt;composer require&lt;/code&gt;. Whether that tradeoff makes sense depends on your workload. If you're generating a 200-row report once a day, PhpSpreadsheet is fine. If you're processing bulk imports, financial data, or anything where users upload files of unpredictable size, the C extension pays for itself quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code Examples&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Creating a workbook and writing data:&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;$book&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;ExcelBook&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Sales Q1'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Headers&lt;/span&gt;
&lt;span class="nv"&gt;$boldFont&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;ExcelFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$boldFont&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bold&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;$headerFormat&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;ExcelFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$headerFormat&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;font&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$boldFont&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$headers&lt;/span&gt; &lt;span class="o"&gt;=&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="s1"&gt;'Date'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Customer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Amount'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Status'&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;$headers&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$col&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$col&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$headerFormat&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Data rows&lt;/span&gt;
&lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-15'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Acme Corp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;15750.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Paid'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-16'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Globex Inc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;8200.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Pending'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1003&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-17'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Initech'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;42000.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Paid'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$dateFormat&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;ExcelFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$dateFormat&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;numberFormat&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;NUMFORMAT_DATE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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;$data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;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;$record&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;$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;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;strtotime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nv"&gt;$dateFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ExcelFormat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AS_DATE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&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;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;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$record&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="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;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Summary 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;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'=SUM(D2:D4)'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sales-q1.xlsx'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading an uploaded file:&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;$book&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;ExcelBook&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;loadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_FILES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'report'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'tmp_name'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSheet&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;$lastRow&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;lastFilledRow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$lastCol&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;lastFilledCol&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&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;firstFilledRow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$lastRow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="o"&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;$rowData&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;readRow&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;$lastCol&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// process $rowData&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rich text in a single cell:&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;$book&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;ExcelBook&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Notes'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$richString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRichString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$boldFont&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;ExcelFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$boldFont&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bold&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;$richString&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Important: '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$boldFont&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$richString&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Review before end of quarter.'&lt;/span&gt;&lt;span class="p"&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;writeRichStr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;$richString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'notes.xlsx'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Working with conditional formatting:&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;$book&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;ExcelBook&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Metrics'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Write some data&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt;&lt;span class="o"&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;$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="nb"&gt;rand&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;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Highlight cells above 75 in green&lt;/span&gt;
&lt;span class="nv"&gt;$cf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addConditionalFormat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$cf&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;font&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;bold&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;$cf&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;font&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;color&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExcelFont&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;COLOR_GREEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$formatting&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;addConditionalFormatting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$formatting&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$formatting&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ExcelConditionalFormat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CELLIS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$cf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;ExcelConditionalFormat&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OPERATOR_GREATERTHAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'75'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'metrics.xlsx'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Installation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Linux / macOS&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;First, get LibXL 4.6.0 or newer from &lt;a href="https://www.libxl.com/" rel="noopener noreferrer"&gt;libxl.com&lt;/a&gt;. It's a commercial library; there's a trial version that works without a license key (limited to ~300 cells and row 0 is inaccessible).&lt;/p&gt;

&lt;p&gt;The easiest path is &lt;a href="https://github.com/php/pie" rel="noopener noreferrer"&gt;PIE&lt;/a&gt; (PHP Installer for Extensions), the official PECL replacement. I added PIE support as part of 2.0:&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/php-excel &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--with-libxl-incdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/libxl/include_c &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--with-libxl-libdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/libxl/lib64
&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;git clone https://github.com/iliaal/php_excel.git
&lt;span class="nb"&gt;cd &lt;/span&gt;php_excel
phpize
./configure &lt;span class="nt"&gt;--with-excel&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--with-libxl-incdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/libxl/include_c &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--with-libxl-libdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/libxl/lib64
make
&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;p&gt;Then add to your &lt;code&gt;php.ini&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;excel.so&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure the LibXL shared library is in your linker path. Either install it to a standard location or set &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"/opt/libxl/lib64"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/ld.so.conf.d/libxl.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;ldconfig
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Windows&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Pre-built DLLs for PHP 8.3 and 8.4 (both x64 and x86, TS and NTS) are attached to every &lt;a href="https://github.com/iliaal/php_excel/releases" rel="noopener noreferrer"&gt;GitHub release&lt;/a&gt;. Download the DLL matching your PHP version and architecture, drop it into your &lt;code&gt;ext&lt;/code&gt; directory, and add to &lt;code&gt;php.ini&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;excel&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll also need &lt;code&gt;libxl.dll&lt;/code&gt; from the LibXL Windows distribution placed somewhere in your system PATH, or in the same directory as &lt;code&gt;php.exe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;License Key Configuration&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have a LibXL license, store the credentials in &lt;code&gt;php.ini&lt;/code&gt; rather than your source code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[excel]&lt;/span&gt;
&lt;span class="py"&gt;excel.license_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Your Name"&lt;/span&gt;
&lt;span class="py"&gt;excel.license_key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"your-license-key-here"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extension reads these automatically. Pass &lt;code&gt;null&lt;/code&gt; for the license parameters in the ExcelBook constructor and they'll be picked up from the ini settings.&lt;/p&gt;

&lt;p&gt;The extension is open source under the PHP-3.01 license. LibXL itself requires a &lt;a href="https://www.libxl.com/" rel="noopener noreferrer"&gt;commercial license&lt;/a&gt; for production use.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/iliaal/php_excel" rel="noopener noreferrer"&gt;Source on GitHub&lt;/a&gt;. Issues, PRs, and stars welcome.&lt;/p&gt;

</description>
      <category>php</category>
      <category>excel</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Teaching AI Agents How to Engineer</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Sat, 02 May 2026 19:32:25 +0000</pubDate>
      <link>https://dev.to/iliaa/teaching-ai-agents-how-to-engineer-ib7</link>
      <guid>https://dev.to/iliaa/teaching-ai-agents-how-to-engineer-ib7</guid>
      <description>&lt;p&gt;Every AI coding agent ships with the same problem: it knows syntax but not discipline. It can write a React component or debug a segfault, but it won't ask "did I verify this actually works?" before declaring victory. It won't split a 400-line diff into reviewable chunks. It won't check if the fix it's about to apply matches the root cause it claims to have found.&lt;/p&gt;

&lt;p&gt;I've spent the past six months fixing that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/iliaal/whetstone" rel="noopener noreferrer"&gt;whetstone&lt;/a&gt; is a Claude Code plugin, and &lt;a href="https://github.com/iliaal/ai-skills" rel="noopener noreferrer"&gt;ai-skills&lt;/a&gt; is its portable counterpart for 35+ AI coding agents. Together they encode the engineering judgment that separates "code that compiles" from "code you'd ship."&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;p&gt;The whetstone plugin ships 30 skills, 19 specialized agents, and 22 commands. Skills are compact instruction sets that fire based on what you're working on. Agents are purpose-built reviewers and researchers. Commands wire them into repeatable workflows.&lt;/p&gt;

&lt;p&gt;The ai-skills repo extracts just the skills into a format any agent can consume: Claude Code (what I use), Codex, OpenCode, Cursor, Gemini CLI, Copilot CLI, and 35+ others. One install command, zero configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Portable skills for any agent&lt;/span&gt;
npx skills add iliaal/ai-skills

&lt;span class="c"&gt;# Full plugin for Claude Code&lt;/span&gt;
/plugin marketplace add https://github.com/iliaal/whetstone
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;whetstone@iliaal-marketplace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Skills aren't prompts
&lt;/h2&gt;

&lt;p&gt;A skill looks like a markdown file. It acts like a behavioral contract.&lt;/p&gt;

&lt;p&gt;Take the &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-debugging" rel="noopener noreferrer"&gt;debugging&lt;/a&gt; skill. Its core rule: no fix until you've identified the root cause with file-and-line evidence, two levels deep in the call chain. The agent must reproduce the bug first, test one hypothesis at a time, and escalate to the user after three failed attempts instead of guessing forever.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-code-review" rel="noopener noreferrer"&gt;code-review&lt;/a&gt; skill runs a two-stage process: spec compliance first, then code quality. When a diff crosses three complexity thresholds (300+ lines, 8+ files, touches security or migrations), it dispatches parallel specialist agents. A security reviewer, a performance analyst, a database guardian, and a maintainability auditor all examine the same diff independently, then merge their findings with deduplication.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-writing" rel="noopener noreferrer"&gt;writing&lt;/a&gt; skill maintains a kill-on-sight vocabulary list. "Delve," "leverage," "robust," "seamless," words that signal machine output. It catches structural tells too: forced triads, dramatic fragments, synonym cycling. Every piece of prose passes through a five-dimension scoring rubric before delivery.&lt;/p&gt;

&lt;p&gt;These aren't suggestions. They're process gates. The agent can't skip them.&lt;/p&gt;

&lt;p&gt;Good prompts still matter, though. Skills encode discipline, but the quality of what you ask for shapes the output just as much. I don't always get that right, so I built a &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-refine-prompt" rel="noopener noreferrer"&gt;refine-prompt&lt;/a&gt; skill that transforms vague instructions into precise, structured ones. It's the skill I use on my own prompts before committing them as commands or workflow steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  How skills activate
&lt;/h2&gt;

&lt;p&gt;Token budget matters. You don't always have a 1M context window, and even when you do, filling it with instructions leaves less room for the actual work. So I put real effort into compression.&lt;/p&gt;

&lt;p&gt;At startup, only skill descriptions load into context. Each description is tuned to the minimum token count that still lets the agent match it to the right request. Too short and the skill never fires. Too long and you're burning context on 30 descriptions before the conversation starts. Finding that line took weeks of testing and iteration.&lt;/p&gt;

&lt;p&gt;When your request matches a skill's keywords, the full body pulls in. Ask about a React component and the &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-react-frontend" rel="noopener noreferrer"&gt;react-frontend&lt;/a&gt; skill fires. Start debugging and the debugging skill loads with its reproduction-first protocol.&lt;/p&gt;

&lt;p&gt;The skills themselves follow the same principle. Core rules live in the SKILL.md body, kept under a 1K token target with a 2K hard cap. Detailed reference material, decision trees, pattern libraries, and extended examples go into &lt;code&gt;references/&lt;/code&gt; files that only load when the agent needs them. The code-review skill's security patterns, the debugging skill's competing-hypotheses framework, the writing skill's full banned-phrase list: all stored as references, pulled only when the agent needs them.&lt;/p&gt;

&lt;p&gt;The plugin takes this further with a hook that intercepts agent dispatches. When the code-review command spawns a security-sentinel agent, the hook injects the relevant skills into that agent's context before it starts work. No manual invocation needed. Fresh agents inherit methodology on dispatch.&lt;/p&gt;

&lt;p&gt;A three-tier priority system prevents overload: methodology skills (code-review, debugging) outrank domain skills (react-frontend, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-python-services" rel="noopener noreferrer"&gt;python-services&lt;/a&gt;), which outrank supporting skills (&lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-writing-tests" rel="noopener noreferrer"&gt;writing-tests&lt;/a&gt;, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-verification-before-completion" rel="noopener noreferrer"&gt;verification&lt;/a&gt;). Cap of five per injection. The agent gets exactly the discipline it needs without drowning in instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow loop
&lt;/h2&gt;

&lt;p&gt;Five commands form the development cycle:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/ia-brainstorm&lt;/code&gt; interviews you one question at a time, produces two or three named approaches with trade-offs, and outputs a design document.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/ia-plan&lt;/code&gt; turns that into atomic tasks with specific file paths, phased into vertical slices capped at five to eight files each.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/ia-work&lt;/code&gt; executes the plan with task tracking, worktree isolation for parallel work, and verification gates after every task.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/ia-review&lt;/code&gt; runs the multi-agent code review described above.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/ia-compound&lt;/code&gt; captures the solution as searchable documentation in &lt;code&gt;docs/solutions/&lt;/code&gt;, so the next time someone hits the same problem, a research agent finds it.&lt;/p&gt;

&lt;p&gt;Each command works standalone. You don't need the full loop for a quick review or a focused debugging session.&lt;/p&gt;

&lt;p&gt;There's a sixth command that sits outside the development cycle but drives the whole project forward: &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-reflect" rel="noopener noreferrer"&gt;reflect&lt;/a&gt;. I run it after every big session. It walks through the full conversation, flags mistakes and friction points, identifies where skills fell short or triggered when they shouldn't have, and proposes fixes. Those findings feed directly into the next release: skill rewrites, hook regex tweaks to reduce false triggers, new memory entries so the agent doesn't repeat the same mistake twice. Most releases trace back to something &lt;code&gt;/ia-reflect&lt;/code&gt; surfaced from a real session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built from real use, not theory
&lt;/h2&gt;

&lt;p&gt;This isn't a weekend project that got a README. Six months of daily use, a release every few days, every change driven by something that broke or wasted my time in an actual session.&lt;/p&gt;

&lt;p&gt;The code-review skill is diagnostic-only because auto-fixing introduced regressions. The debugging skill caps attempts at three because without that limit, agents would silently try 15 broken fixes before asking for help. The writing skill's banned-phrase list grows every time I catch the agent producing "in today's rapidly evolving landscape."&lt;/p&gt;

&lt;p&gt;The plugin includes a skill distillery that automates this refinement loop. It harvests session logs from actual Claude Code usage, scores skill effectiveness via LLM-as-judge evaluation, and can evolve skills through DSPy optimization. A regression suite with 336 trigger-pattern fixtures gates every release. If a skill fires when it shouldn't, or doesn't fire when it should, the release fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it covers
&lt;/h2&gt;

&lt;p&gt;The skills span the stacks I work in and the problems I hit most:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Languages and frameworks&lt;/strong&gt;: &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-react-frontend" rel="noopener noreferrer"&gt;React 19&lt;/a&gt; with App Router patterns, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-nodejs-backend" rel="noopener noreferrer"&gt;Node.js backend&lt;/a&gt; architecture (Express, Fastify, Hono), &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-python-services" rel="noopener noreferrer"&gt;Python services&lt;/a&gt; with FastAPI, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-php-laravel" rel="noopener noreferrer"&gt;PHP/Laravel&lt;/a&gt;, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-tailwind-css" rel="noopener noreferrer"&gt;Tailwind CSS&lt;/a&gt;, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-rust-systems" rel="noopener noreferrer"&gt;Rust&lt;/a&gt;, even &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-pinescript" rel="noopener noreferrer"&gt;Pine Script&lt;/a&gt; for TradingView.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engineering process&lt;/strong&gt;: &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-planning" rel="noopener noreferrer"&gt;planning&lt;/a&gt; with vertical slicing, code review with specialist dispatch, debugging with root-cause discipline, test writing, verification gates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;: &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-postgresql" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt; performance and schema design, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-terraform" rel="noopener noreferrer"&gt;Terraform&lt;/a&gt;, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-linux-bash-scripting" rel="noopener noreferrer"&gt;Linux/bash scripting&lt;/a&gt;, Docker, cloud architecture across AWS/Azure/GCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI-native patterns&lt;/strong&gt;: &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-orchestrating-swarms" rel="noopener noreferrer"&gt;multi-agent orchestration&lt;/a&gt;, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-agent-native-architecture" rel="noopener noreferrer"&gt;agent-native architecture&lt;/a&gt; audits, &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-meta-prompting" rel="noopener noreferrer"&gt;meta-prompting&lt;/a&gt; techniques, skill refinement workflows.&lt;/p&gt;

&lt;p&gt;Each skill encodes opinions. The &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-php-laravel" rel="noopener noreferrer"&gt;PHP skill&lt;/a&gt; mandates &lt;code&gt;declare(strict_types=1)&lt;/code&gt; everywhere and PHPStan level 8+. The &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-nodejs-backend" rel="noopener noreferrer"&gt;Node.js skill&lt;/a&gt; enforces layered architecture with no cross-layer HTTP imports. The &lt;a href="https://github.com/iliaal/whetstone/tree/main/plugins/whetstone/skills/ia-react-frontend" rel="noopener noreferrer"&gt;React skill&lt;/a&gt; routes "should I use an effect?" questions through a decision tree that almost always answers "no." These aren't generic best practices scraped from documentation. They're the rules that prevent the bugs I've watched agents introduce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why portable skills matter
&lt;/h2&gt;

&lt;p&gt;AI coding agents are proliferating. Most developers will use two or three over the next year, depending on context. Skills that only work in one tool create vendor lock-in for your engineering standards.&lt;/p&gt;

&lt;p&gt;The ai-skills repo solves this. Install once, and your debugging discipline, review process, and code standards follow you across tools. The format is simple markdown with YAML frontmatter. Any agent that supports skill loading can consume them.&lt;/p&gt;

&lt;p&gt;The whetstone plugin adds what only a deep integration can: agents that spawn other agents, hooks that inject context, commands that orchestrate multi-step workflows. If you're in Claude Code, you get the full system. If you're elsewhere, you still get the discipline.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# All skills (works everywhere)&lt;/span&gt;
npx skills add iliaal/ai-skills

&lt;span class="c"&gt;# Just one skill&lt;/span&gt;
npx skills add iliaal/ai-skills &lt;span class="nt"&gt;-s&lt;/span&gt; code-review

&lt;span class="c"&gt;# The full plugin (Claude Code)&lt;/span&gt;
/plugin marketplace add https://github.com/iliaal/whetstone
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;whetstone@iliaal-marketplace

&lt;span class="c"&gt;# Convert the plugin for Codex&lt;/span&gt;
git clone https://github.com/iliaal/whetstone
&lt;span class="nb"&gt;cd &lt;/span&gt;whetstone &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; bun &lt;span class="nb"&gt;install
&lt;/span&gt;bun run src/index.ts &lt;span class="nb"&gt;install&lt;/span&gt; ./plugins/whetstone &lt;span class="nt"&gt;--to&lt;/span&gt; codex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Codex converter transforms agents into skills, commands into prompts, and rewrites Claude Code syntax into Codex equivalents. It outputs a &lt;code&gt;.codex/&lt;/code&gt; directory with everything mapped. The same CLI supports &lt;code&gt;--to opencode&lt;/code&gt; and &lt;code&gt;--also&lt;/code&gt; for multi-target conversion.&lt;/p&gt;

&lt;p&gt;Both repos are MIT-licensed and on GitHub. The plugin ships weekly. The skills mirror syncs with every release.&lt;/p&gt;

&lt;p&gt;Six months of daily use have taught me that AI agents don't lack capability. They lack process. Give them clear rules, verification gates, and escape hatches for when they're stuck, and the output quality jumps. That's what these projects encode, and they get a little better every week.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>claude</category>
    </item>
    <item>
      <title>php_clickhouse 0.8.1: Three Releases Later, Stable</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Fri, 01 May 2026 12:12:54 +0000</pubDate>
      <link>https://dev.to/iliaa/phpclickhouse-081-three-releases-later-stable-1aid</link>
      <guid>https://dev.to/iliaa/phpclickhouse-081-three-releases-later-stable-1aid</guid>
      <description>&lt;p&gt;The &lt;a href="https://ilia.ws/blog/php-clickhouse-a-native-clickhouse-client-for-php-picking-up-where-seasclick-left-off" rel="noopener noreferrer"&gt;launch post for php_clickhouse 0.6.0&lt;/a&gt; covered the framing: native binary protocol, soft fork of the stalled SeasClick, modern ClickHouse types, 30-40% faster than HTTP at high throughput. That post landed April 25, 2026. Today (May 1, 2026) the current tag is 0.8.1, and I'm calling the extension stable.&lt;/p&gt;

&lt;p&gt;The six days in between were a focused quality cycle, not a feature sprint. Three buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Performance.&lt;/strong&gt; Insert and write paths build native ClickHouse columns one at a time directly from row-major input. Peak intermediate PHP memory dropped from &lt;code&gt;N_rows × N_cols&lt;/code&gt; zvals to one column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; Strict full-consumption parsers across &lt;code&gt;Map&lt;/code&gt;, narrow-int, Int128 / UInt128, geo, DateTime64, Time64, hex literals, and typed parameters. Wrong-type input throws instead of corrupting memory or coercing silently to zero. Recursive type-conversion gained a depth cap so adversarial server schemas can't blow the stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stability.&lt;/strong&gt; Per-Client state moved from file-scope &lt;code&gt;std::map&lt;/code&gt; banks onto the &lt;code&gt;zend_object&lt;/code&gt; itself. Unblocks ZTS, plugs leaks on bailout, fixes a refcount bug on the progress callback. Insert path recovers the native handle on every server-side rejection point so a thrown insert no longer wedges the connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three releases (0.7.0, 0.8.0, 0.8.1) closed the API gap with the most-used HTTP client, refactored the extension's state model, hardened the insert surface, and surfaced one upstream UB fix that has since merged into clickhouse-cpp.&lt;/p&gt;

&lt;p&gt;Here's the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  0.7.0: Closing the Ergonomics Gap with smi2/phpClickHouse
&lt;/h2&gt;

&lt;p&gt;The native binary protocol gives you 30-40% throughput. Most teams won't trade a familiar API for that, so the native client has to match the ergonomic surface of the most-used PHP HTTP client (&lt;code&gt;smi2/phpClickHouse&lt;/code&gt;). 0.7.0 is the release that actually does that.&lt;/p&gt;

&lt;p&gt;What landed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;setSettings(array)&lt;/code&gt; for client-wide ClickHouse settings (&lt;code&gt;max_execution_time&lt;/code&gt;, &lt;code&gt;max_memory_usage&lt;/code&gt;, &lt;code&gt;async_insert&lt;/code&gt;). Per-call settings as a 5th array argument on &lt;code&gt;select()&lt;/code&gt; / &lt;code&gt;insert()&lt;/code&gt; / &lt;code&gt;execute()&lt;/code&gt; / &lt;code&gt;writeStart()&lt;/code&gt;. Per-call overrides global.&lt;/li&gt;
&lt;li&gt;Server-side typed parameters via the &lt;code&gt;{name:Type}&lt;/code&gt; placeholder syntax. Routed through &lt;code&gt;Query::SetParam&lt;/code&gt; so the server quotes and parses according to the declared type. Plain &lt;code&gt;{name}&lt;/code&gt; placeholders keep their existing client-side identifier-substitution behavior. Arrays format as ClickHouse array literals so &lt;code&gt;Array(UInt32)&lt;/code&gt;, &lt;code&gt;Array(String)&lt;/code&gt; round-trip cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;setProgressCallback(?callable)&lt;/code&gt; invoked for every &lt;code&gt;Progress&lt;/code&gt; packet during a query (&lt;code&gt;rows&lt;/code&gt;, &lt;code&gt;bytes&lt;/code&gt;, &lt;code&gt;total_rows&lt;/code&gt;, &lt;code&gt;written_rows&lt;/code&gt;, &lt;code&gt;written_bytes&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getStatistics()&lt;/code&gt; returning &lt;code&gt;rows_read&lt;/code&gt;, &lt;code&gt;bytes_read&lt;/code&gt;, &lt;code&gt;total_rows&lt;/code&gt;, &lt;code&gt;written_rows&lt;/code&gt;, &lt;code&gt;written_bytes&lt;/code&gt;, &lt;code&gt;blocks&lt;/code&gt;, &lt;code&gt;rows_before_limit&lt;/code&gt;, &lt;code&gt;applied_limit&lt;/code&gt;, &lt;code&gt;elapsed_ms&lt;/code&gt; from the last completed query. Reset at the start of each query.&lt;/li&gt;
&lt;li&gt;Structured &lt;code&gt;ClickHouseException&lt;/code&gt;: &lt;code&gt;server_code&lt;/code&gt; (e.g. 159 for &lt;code&gt;TIMEOUT_EXCEEDED&lt;/code&gt;), &lt;code&gt;server_name&lt;/code&gt; (&lt;code&gt;DB::Exception&lt;/code&gt;), &lt;code&gt;query_id&lt;/code&gt;. Populated on server errors and on any throw with a query-id context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;insertAssoc(table, rows)&lt;/code&gt; derives the column list from the keys of the first row.&lt;/li&gt;
&lt;li&gt;SQL helpers: &lt;code&gt;databaseSize()&lt;/code&gt;, &lt;code&gt;tablesSize()&lt;/code&gt;, &lt;code&gt;partitions()&lt;/code&gt;, &lt;code&gt;showTables()&lt;/code&gt;, &lt;code&gt;showCreateTable()&lt;/code&gt;, &lt;code&gt;getServerUptime()&lt;/code&gt;. Each validates identifiers against the safe-character set.&lt;/li&gt;
&lt;li&gt;Sub-second timeouts via &lt;code&gt;connect_timeout_ms&lt;/code&gt;, &lt;code&gt;receive_timeout_ms&lt;/code&gt;, &lt;code&gt;send_timeout_ms&lt;/code&gt; config keys. Override the existing seconds-based keys when present.&lt;/li&gt;
&lt;li&gt;Per-client query log accumulator: &lt;code&gt;enableLogQueries(bool)&lt;/code&gt; toggles, &lt;code&gt;getLogQueries()&lt;/code&gt; returns and clears. Each entry carries &lt;code&gt;sql&lt;/code&gt;, &lt;code&gt;query_id&lt;/code&gt;, &lt;code&gt;elapsed_ms&lt;/code&gt;, &lt;code&gt;rows_read&lt;/code&gt;, &lt;code&gt;bytes_read&lt;/code&gt;, &lt;code&gt;error_code&lt;/code&gt;, &lt;code&gt;error_message&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other under-the-hood change in 0.7.0 was migrating to a stub-driven arginfo workflow (&lt;code&gt;clickhouse.stub.php&lt;/code&gt; → generated &lt;code&gt;clickhouse_arginfo.h&lt;/code&gt;). Method parameter and return types are now declared at the engine boundary and visible to Reflection, IDEs, and static analyzers. Behavior is unchanged for correctly-typed callers; wrong-type callers now hit ZPP at the boundary instead of a custom thrown exception inside the method body.&lt;/p&gt;

&lt;p&gt;None of 0.7.0 is novel on its own. The point is that without these the native client made you pay an ergonomics tax to get the speed. 0.7.0 settles that tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  0.8.0: Per-Object State, ZTS, and Streaming
&lt;/h2&gt;

&lt;p&gt;The 0.6.0 / 0.7.0 surface stored per-Client state in seven file-scope &lt;code&gt;std::map&amp;lt;int, ...&amp;gt;&lt;/code&gt; banks keyed on &lt;code&gt;Z_OBJ_HANDLE&lt;/code&gt;: the &lt;code&gt;Client*&lt;/code&gt;, the in-flight insert Block, the ClientStats, the global settings, the progress and profile callbacks, the log toggle, the query log buffer.&lt;/p&gt;

&lt;p&gt;That works, and it has three durability problems baked in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No ZTS support.&lt;/strong&gt; Threaded SAPIs share that file-scope state across threads. The 0.6.0 code gated MINIT with a hard error when &lt;code&gt;--enable-zts&lt;/code&gt; was on. ClickHouse from RoadRunner / FrankenPHP / Swoole / php-pm was a non-starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaks on bailout.&lt;/strong&gt; PHP's userspace &lt;code&gt;__destruct&lt;/code&gt; doesn't run on fatal errors, so the map entries (and the underlying &lt;code&gt;Client*&lt;/code&gt; and any half-open insert stream) leaked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refcount bug on the progress callback.&lt;/strong&gt; A struct copy of the registered callable went stale when the calling scope went out of scope, and the next progress packet hit a freed zval.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;0.8.0 moved the per-Client state onto the &lt;code&gt;zend_object&lt;/code&gt; itself via custom &lt;code&gt;create_object&lt;/code&gt; / &lt;code&gt;free_obj&lt;/code&gt; handlers. The seven file-scope maps disappear entirely. ZTS gating at MINIT was deleted in the same release.&lt;/p&gt;

&lt;p&gt;The refactor unblocks three things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Threaded SAPIs.&lt;/strong&gt; No global state to thread-isolate, so ZTS Linux is a first-class target now. CI grew a &lt;code&gt;linux-zts&lt;/code&gt; job (PHP 8.4 ZTS built from source).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup on bailout.&lt;/strong&gt; &lt;code&gt;free_obj&lt;/code&gt; runs unconditionally, including on fatal errors. The &lt;code&gt;Client*&lt;/code&gt; and any half-open insert stream get torn down properly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The progress-callback fix lands.&lt;/strong&gt; &lt;code&gt;setProgressCallback&lt;/code&gt; now uses &lt;code&gt;ZVAL_COPY&lt;/code&gt; instead of a struct copy, so the callable doesn't get freed out from under the next packet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Windows &lt;code&gt;config.w32&lt;/code&gt; shipped in the same release, rewritten from a 9-line warning stub to a full Windows build script that mirrors &lt;code&gt;config.m4&lt;/code&gt;'s source list and flags. Optional &lt;code&gt;--enable-clickhouse-openssl&lt;/code&gt; plumbing is mirrored via &lt;code&gt;CHECK_LIB("libssl.lib", ...)&lt;/code&gt;. CI exercises Windows as a build + extension-load smoke test (no live ClickHouse on Windows yet).&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming reads
&lt;/h3&gt;

&lt;p&gt;0.8.0 introduced two new read paths for result sets that don't fit comfortably in a single PHP array:&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;$it&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;selectStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT id, payload FROM events WHERE day = today()"&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;$it&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="nf"&gt;process&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;selectStream()&lt;/code&gt; returns a &lt;code&gt;ClickHouseRowIterator&lt;/code&gt; (&lt;code&gt;Iterator&lt;/code&gt; + &lt;code&gt;Countable&lt;/code&gt;) that walks blocks lazily. The iterator survives &lt;code&gt;unset($client)&lt;/code&gt; because blocks own their column data via &lt;code&gt;shared_ptr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For unbounded streams where you don't want to count or rewind:&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;selectStreamCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"SELECT id, body FROM events_unbounded"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$row&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;writeToS3&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callback fires once per row as blocks arrive, never accumulating the full result.&lt;/p&gt;

&lt;p&gt;The plain &lt;code&gt;select()&lt;/code&gt; path is unchanged and remains the faster choice when you actually want a full PHP array. The streaming variants exist for the row-millions case where you don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Geo, LowCardinality(Nullable), and the Map matrix
&lt;/h3&gt;

&lt;p&gt;The type surface expanded too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Geo types Point, Ring, Polygon, MultiPolygon round-trip via &lt;code&gt;ColumnGeo&lt;/code&gt;. Point as &lt;code&gt;[Float64, Float64]&lt;/code&gt;, the others as nested arrays.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LowCardinality(Nullable(String))&lt;/code&gt; and &lt;code&gt;LowCardinality(Nullable(FixedString))&lt;/code&gt; round-trip on read and write.&lt;/li&gt;
&lt;li&gt;The insert path now accepts any &lt;code&gt;Map(K, V)&lt;/code&gt; over scalar K and V (String, all signed/unsigned integer widths, Float32/64, UUID) plus &lt;code&gt;LowCardinality(String)&lt;/code&gt; keys and values. The read path mirrors the same matrix except for &lt;code&gt;LowCardinality&lt;/code&gt; keys (vendor gap). Previously only five hardcoded combinations worked.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SimpleAggregateFunction(f, T)&lt;/code&gt; reads transparently as &lt;code&gt;T&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Geo support unblocks one of the two large reasons people stayed on the HTTP client. The other was streaming.&lt;/p&gt;

&lt;h3&gt;
  
  
  Other 0.8.0 surfaces worth naming
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;selectStatement()&lt;/code&gt; returns a &lt;code&gt;ClickHouseStatement&lt;/code&gt; result wrapper: &lt;code&gt;Iterator&lt;/code&gt;, &lt;code&gt;Countable&lt;/code&gt;, &lt;code&gt;ArrayAccess&lt;/code&gt;, &lt;code&gt;JsonSerializable&lt;/code&gt;, plus &lt;code&gt;fetchOne()&lt;/code&gt; / &lt;code&gt;fetchKeyPair()&lt;/code&gt; / &lt;code&gt;fetchColumn()&lt;/code&gt; / &lt;code&gt;toArray()&lt;/code&gt; / &lt;code&gt;statistics()&lt;/code&gt;. Read-only (&lt;code&gt;offsetSet&lt;/code&gt; / &lt;code&gt;offsetUnset&lt;/code&gt; throw). Carries a per-call stats snapshot so it survives the client running other queries afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;setVerbose(bool|callable)&lt;/code&gt; for protocol-level lifecycle tracing. Pass &lt;code&gt;true&lt;/code&gt; for JSON lines on STDERR, or a callable invoked with &lt;code&gt;($eventName, $context)&lt;/code&gt;. Events: &lt;code&gt;select_start&lt;/code&gt;, &lt;code&gt;data_block&lt;/code&gt;, &lt;code&gt;select_finish&lt;/code&gt;, &lt;code&gt;execute_start&lt;/code&gt;, &lt;code&gt;execute_finish&lt;/code&gt;, &lt;code&gt;server_exception&lt;/code&gt;. No-op when off, so the hot path stays cheap on production.&lt;/li&gt;
&lt;li&gt;DDL helpers: &lt;code&gt;isExists()&lt;/code&gt;, &lt;code&gt;showDatabases()&lt;/code&gt;, &lt;code&gt;showProcesslist()&lt;/code&gt;, &lt;code&gt;getServerVersion()&lt;/code&gt;, &lt;code&gt;tableSize()&lt;/code&gt;, &lt;code&gt;truncateTable()&lt;/code&gt;, &lt;code&gt;dropPartition()&lt;/code&gt;. All identifier args validated; &lt;code&gt;dropPartition&lt;/code&gt; SQL-escapes the partition value.&lt;/li&gt;
&lt;li&gt;Client introspection: &lt;code&gt;resetConnection()&lt;/code&gt;, &lt;code&gt;getServerInfo()&lt;/code&gt; (name, version, revision, timezone, display_name), &lt;code&gt;getCurrentEndpoint()&lt;/code&gt; (host/port of the active endpoint when an endpoints[] pool is in use), &lt;code&gt;setProfileCallback()&lt;/code&gt;, &lt;code&gt;ping_before_query&lt;/code&gt; config key.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;query_id&lt;/code&gt; echoed through &lt;code&gt;getStatistics()&lt;/code&gt; so callers can correlate a stats snapshot to a server-side query in &lt;code&gt;system.query_log&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;smi2-style sugar: &lt;code&gt;setSettings()&lt;/code&gt; returns &lt;code&gt;$this&lt;/code&gt; for chaining, &lt;code&gt;setSetting(key, value)&lt;/code&gt; for the single-key form, &lt;code&gt;setDatabase(string)&lt;/code&gt; issues &lt;code&gt;USE&lt;/code&gt; and updates the cached default used by &lt;code&gt;databaseSize()&lt;/code&gt; / &lt;code&gt;showTables()&lt;/code&gt;, getter aliases (&lt;code&gt;getServerCode()&lt;/code&gt;, &lt;code&gt;getServerName()&lt;/code&gt;, &lt;code&gt;getQueryId()&lt;/code&gt;) on &lt;code&gt;ClickHouseException&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  IPv4 / IPv6 crash, fixed
&lt;/h3&gt;

&lt;p&gt;This one's worth calling out as a bug-of-the-release. clickhouse-cpp v2.6.1 made &lt;code&gt;ColumnIPv4&lt;/code&gt; / &lt;code&gt;ColumnIPv6&lt;/code&gt; siblings of (not subclasses of) &lt;code&gt;ColumnUInt32&lt;/code&gt; / &lt;code&gt;ColumnFixedString&lt;/code&gt;. The 0.6.0 / 0.7.0 read paths were doing &lt;code&gt;As&amp;lt;ColumnUInt32&amp;gt;()&lt;/code&gt; / &lt;code&gt;As&amp;lt;ColumnFixedString&amp;gt;()&lt;/code&gt; on IP columns, which now returned null instead of dispatching. The next dereference segfaulted the worker.&lt;/p&gt;

&lt;p&gt;Fixed by switching to &lt;code&gt;ColumnIPv*::AsString(row)&lt;/code&gt; for canonical dotted-quad / &lt;code&gt;::1&lt;/code&gt; form. If you hit a crash on IP column reads pre-0.8.0, this is why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Distribution: pre-built binaries via PIE
&lt;/h3&gt;

&lt;p&gt;Binaries for Linux glibc (x86_64 + arm64) and macOS (x86_64 + arm64) are now available. On a supported platform the install collapses to one line:&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/php_clickhouse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No vendored clickhouse-cpp build, no abseil compile, no five-minute &lt;code&gt;make&lt;/code&gt;. TLS still requires the source build (&lt;code&gt;pie install iliaal/php_clickhouse --enable-clickhouse-openssl&lt;/code&gt;), but that's a smaller set of users.&lt;/p&gt;

&lt;h2&gt;
  
  
  0.8.1: The Insert Path That Recovers
&lt;/h2&gt;

&lt;p&gt;0.8.0 was the architecture release. 0.8.1 was the hardening pass: nine rounds of reviewer-driven fixes, mostly on the insert and write surface plus the type-conversion boundary.&lt;/p&gt;

&lt;p&gt;The headline bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ClickHouseException: cannot execute query while inserting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a server-side insert rejection (missing table, bad column, CHECK constraint, schema drift) threw out of &lt;code&gt;BeginInsert&lt;/code&gt; / &lt;code&gt;SendInsertBlock&lt;/code&gt; / &lt;code&gt;EndInsert&lt;/code&gt;, the vendored client's &lt;code&gt;inserting_&lt;/code&gt; flag stayed set. Subsequent &lt;code&gt;select&lt;/code&gt; / &lt;code&gt;execute&lt;/code&gt; on the same handle threw the message above until the caller manually called &lt;code&gt;resetConnection()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;0.8.1 wraps every server-side rejection point in a connection-reset-then-rethrow. Same handle stays usable.&lt;/p&gt;

&lt;p&gt;Destructor cleanup mirrors the same dirty/clean recovery split: an in-flight streaming insert with sent blocks is dropped via &lt;code&gt;ResetConnection&lt;/code&gt; on &lt;code&gt;unset()&lt;/code&gt; rather than committed via &lt;code&gt;EndInsert&lt;/code&gt;. Clean sessions still &lt;code&gt;EndInsert&lt;/code&gt;. Avoids partial commits on script bailout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory: column-at-a-time insert
&lt;/h3&gt;

&lt;p&gt;Pre-0.8.1, &lt;code&gt;insert()&lt;/code&gt; and &lt;code&gt;write()&lt;/code&gt; materialized a full column-major PHP zval matrix from the user's row-major input before building the native ClickHouse columns. For a 1M-row × 30-column insert that's 30M zvals sitting in PHP memory while the column build runs.&lt;/p&gt;

&lt;p&gt;0.8.1 builds native columns one at a time directly from the row-major input. Peak intermediate PHP memory drops from &lt;code&gt;N_rows × N_cols&lt;/code&gt; to one column.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;insertAssoc()&lt;/code&gt; benefited from the same change: no more positional copy of input rows. The column gatherer reads each column directly from the original associative rows, and key validation uses &lt;code&gt;zend_hash_exists&lt;/code&gt; against the first row's HashTable instead of allocating a new &lt;code&gt;std::string&lt;/code&gt; for every row key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strict parsers across the type surface
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Map&lt;/code&gt;, narrow-int (Int8 / Int16 / Int32 / their unsigned siblings), Int128 / UInt128, geo, DateTime64, Time64 insert paths now use full-consumption strict parsers. Non-numeric strings, fractional doubles, non-finite floats, and out-of-range values throw instead of silently coercing to 0 / 0.0 inside the column.&lt;/p&gt;

&lt;p&gt;UInt64 inserts gained a shared &lt;code&gt;strict_zval_u64&lt;/code&gt; parser that accepts decimal and hex strings above &lt;code&gt;ZEND_LONG_MAX&lt;/code&gt; on both the scalar and &lt;code&gt;Map(*, UInt64)&lt;/code&gt; paths. Reads continue to surface upper-half values as decimal strings.&lt;/p&gt;

&lt;p&gt;The class of bug strict parsing eliminates is the worst kind of insert bug: the string &lt;code&gt;"foo"&lt;/code&gt; lands in an &lt;code&gt;Int32&lt;/code&gt; column as &lt;code&gt;0&lt;/code&gt;, no error, no audit trail. Now it throws.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validation and reentry
&lt;/h3&gt;

&lt;p&gt;A few smaller fixes worth naming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;write()&lt;/code&gt; rejects rows narrower or wider than the &lt;code&gt;writeStart&lt;/code&gt; column count. The previous path took the first row's element count as authoritative, so &lt;code&gt;[1]&lt;/code&gt; against &lt;code&gt;writeStart(t, ['a','b'])&lt;/code&gt; landed &lt;code&gt;1&lt;/code&gt; into column &lt;code&gt;a&lt;/code&gt; with &lt;code&gt;b&lt;/code&gt; defaulted server-side.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;insert()&lt;/code&gt; rejects rows with extra positional or named cells. A row like &lt;code&gt;[1, 99]&lt;/code&gt; against a single-column table previously landed as &lt;code&gt;1&lt;/code&gt; with &lt;code&gt;99&lt;/code&gt; lost.&lt;/li&gt;
&lt;li&gt;A failed later &lt;code&gt;write()&lt;/code&gt; no longer commits previously sent blocks. The catch path tracks whether any block has been sent in the current &lt;code&gt;writeStart()&lt;/code&gt; session and chooses &lt;code&gt;ResetConnection&lt;/code&gt; (discard) over &lt;code&gt;EndInsert&lt;/code&gt; (commit) on a dirty session.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;insertAssoc()&lt;/code&gt; rejects integer-keyed later rows and any key-set drift from the first row. The first row defines the column set; every later row must match.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Enum8&lt;/code&gt; / &lt;code&gt;Enum16&lt;/code&gt; inserts reject undeclared integers, NULL on non-Nullable columns, and unknown string names.&lt;/li&gt;
&lt;li&gt;Single-token placeholder validator: &lt;code&gt;{name}&lt;/code&gt; placeholders accept exactly one identifier and reject comma-separated lists. Comma-list callers must use array form.&lt;/li&gt;
&lt;li&gt;Same-client reentry guard: a userland progress / profile callback that fires another query on the same handle now throws cleanly instead of crashing the worker on the next &lt;code&gt;ReceiveData&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Recursive type-conversion depth cap (32) keeps deeply nested structures (&lt;code&gt;Array(Array(...))&lt;/code&gt;, &lt;code&gt;Map(K, Tuple(...))&lt;/code&gt;) from blowing the stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;23 new PHPTs (072–094) pin all of the above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upstream: One Fix Merged Back to clickhouse-cpp 🎆
&lt;/h2&gt;

&lt;p&gt;The ASan job added in 0.8.0 caught a latent UB in the vendored library that nobody had been hitting in production, but UBSan flagged on every empty &lt;code&gt;LowCardinality(String)&lt;/code&gt; value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;runtime error: null pointer passed as argument 2,
  which is declared to never be null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ColumnStringBlock::AppendUnsafe&lt;/code&gt; was calling &lt;code&gt;memcpy(pos, str.data(), str.size())&lt;/code&gt; unconditionally. When &lt;code&gt;str&lt;/code&gt; was constructed from an empty &lt;code&gt;std::string&lt;/code&gt;, &lt;code&gt;str.data()&lt;/code&gt; is allowed to be &lt;code&gt;NULL&lt;/code&gt;, and libc declares &lt;code&gt;memcpy&lt;/code&gt;'s second argument with &lt;code&gt;__attribute__((nonnull))&lt;/code&gt; regardless of the size. Every libc no-ops &lt;code&gt;memcpy(_, NULL, 0)&lt;/code&gt; in practice, so the bug was benign on real workloads, but the false-positive UBSan trip was noising the extension's ASan job and obscuring real findings.&lt;/p&gt;

&lt;p&gt;Patch: guard the &lt;code&gt;memcpy&lt;/code&gt; with &lt;code&gt;if (str.size() &amp;gt; 0)&lt;/code&gt;. Submitted upstream as &lt;a href="https://github.com/ClickHouse/clickhouse-cpp/pull/489" rel="noopener noreferrer"&gt;clickhouse-cpp#489&lt;/a&gt;, merged 2026-04-27. The local patch in &lt;code&gt;lib/clickhouse-cpp/LOCAL_PATCHES.md&lt;/code&gt; will drop the next time the vendored library bumps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Still Missing
&lt;/h2&gt;

&lt;p&gt;Two limitations carry forward from clickhouse-cpp v2.6.1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SELECT ... WITH TOTALS&lt;/code&gt; and &lt;code&gt;SETTINGS extremes=1&lt;/code&gt; throw &lt;code&gt;unimplemented 7&lt;/code&gt; from the cpp layer. The vendored library does not dispatch the Totals / Extremes packet types (&lt;a href="https://github.com/ClickHouse/clickhouse-cpp/issues/297" rel="noopener noreferrer"&gt;upstream issue #297&lt;/a&gt;). &lt;code&gt;getTotals()&lt;/code&gt; / &lt;code&gt;getExtremes()&lt;/code&gt; are deferred.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Map(LowCardinality(K), V)&lt;/code&gt; reads are not yet decoded by the vendored library (writes succeed). &lt;code&gt;showProcesslist()&lt;/code&gt; selects a fixed projection of standard columns to avoid the unsupported Map columns (&lt;code&gt;ProfileEvents&lt;/code&gt;, &lt;code&gt;Settings&lt;/code&gt;, &lt;code&gt;used_*&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If either blocks your workload, file an issue at &lt;a href="https://github.com/iliaal/php_clickhouse" rel="noopener noreferrer"&gt;github.com/iliaal/php_clickhouse&lt;/a&gt; with the schema and a minimal repro. Both are upstream and tracked.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;a href="https://github.com/iliaal/php_clickhouse" rel="noopener noreferrer"&gt;github.com/iliaal/php_clickhouse&lt;/a&gt;. Install via PIE: &lt;code&gt;pie install iliaal/php_clickhouse&lt;/code&gt; (add &lt;code&gt;--enable-clickhouse-openssl&lt;/code&gt; for TLS). The original launch post that framed the fork story sits at &lt;a href="https://ilia.ws/blog/php-clickhouse-a-native-clickhouse-client-for-php-picking-up-where-seasclick-left-off" rel="noopener noreferrer"&gt;ilia.ws&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>clickhouse</category>
    </item>
    <item>
      <title>Driving TradingView Desktop From an AI Agent</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Wed, 29 Apr 2026 22:11:00 +0000</pubDate>
      <link>https://dev.to/iliaa/driving-tradingview-desktop-from-an-ai-agent-7d</link>
      <guid>https://dev.to/iliaa/driving-tradingview-desktop-from-an-ai-agent-7d</guid>
      <description>&lt;p&gt;Anyone who's tried AI-assisted trading research has hit the same wall.&lt;/p&gt;

&lt;p&gt;The agent has no native access to your charts. You end up copy-pasting symbols, indicator values, screenshots, and Pine Script back and forth between TradingView and Claude or Cursor. The tools that try to fix this fall into two camps: route market data through a third-party API (added latency, added cost, their interpretation of your bars), or pollute your TradingView chart with helper indicators just so an agent can read them back.&lt;/p&gt;

&lt;p&gt;There's a third path that's more obvious in retrospect: drive your &lt;em&gt;local&lt;/em&gt; TradingView Desktop through the Chrome DevTools Protocol that the app already exposes on port 9222 when you launch it with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt;. The agent talks to the same TradingView you already use, reads what your chart actually shows, executes Pine Script through TradingView's own runtime. Same data, same indicators, same features, just with an agent in the loop.&lt;/p&gt;

&lt;p&gt;A project called &lt;code&gt;tradesdontlie/tradingview-mcp&lt;/code&gt; started down this path. Then it stalled with TV Desktop 3.1.0 incompatibilities and a handful of bugs, and the maintainer went quiet on PRs. I forked it, and now 71 commits later just tagged &lt;code&gt;1.0.0&lt;/code&gt; of the fork. This post covers what I added and what the tool doesn't do.&lt;/p&gt;

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

&lt;p&gt;The release is at &lt;a href="https://github.com/iliaal/tradingview-mcp/releases/tag/1.0.0" rel="noopener noreferrer"&gt;https://github.com/iliaal/tradingview-mcp/releases/tag/1.0.0&lt;/a&gt; with the per-tool changelog. The high-level numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;78 → 96 tools (18 new MCP tools)&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;tv&lt;/code&gt; CLI (30 commands, 66 subcommands) mirroring the MCP surface&lt;/li&gt;
&lt;li&gt;TV Desktop 3.1.0 compatibility across the whole surface&lt;/li&gt;
&lt;li&gt;338 offline tests (pattern detection, multi-timeframe, replay, CLI routing)&lt;/li&gt;
&lt;li&gt;Removed &lt;code&gt;ui_evaluate&lt;/code&gt; from the upstream surface (it accepted arbitrary JavaScript in the user's authenticated TV session)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pine Script Lifecycle
&lt;/h2&gt;

&lt;p&gt;The original project let an agent inspect Pine Script, but only barely. Editing, saving, switching, and version history all required the user to click through TradingView's UI. The 1.0.0 fork closes that gap with eight new tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pine_save_as&lt;/code&gt; and &lt;code&gt;pine_rename&lt;/code&gt; for naming and copying scripts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pine_version_history&lt;/code&gt;, &lt;code&gt;pine_delete&lt;/code&gt;, &lt;code&gt;pine_switch_script&lt;/code&gt; for managing the Pine library&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pine_smart_compile&lt;/code&gt; (auto-detects whether to add or update an indicator on the chart; returns &lt;code&gt;elapsed_ms&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pine_analyze&lt;/code&gt; (offline static analysis; catches typos, unused vars, deprecated patterns before compile)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pine_check&lt;/code&gt; (server-side compile without putting the script on a chart; useful for CI-style validation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;pine_check&lt;/code&gt; + &lt;code&gt;pine_analyze&lt;/code&gt; pair is the unlock for AI-assisted Pine debugging. The agent writes a draft, runs &lt;code&gt;pine_analyze&lt;/code&gt; for cheap structural checks, then &lt;code&gt;pine_check&lt;/code&gt; for the real compile, reads errors, fixes, repeats. The user never has to add an unfinished script to the chart just to see whether it compiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pine Drawing Readers
&lt;/h2&gt;

&lt;p&gt;This is the part I keep using most. Five new tools let the agent read what a Pine indicator actually drew, after it's running on the chart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;data_get_pine_lines&lt;/code&gt;: horizontal price levels (support, resistance, trend lines)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data_get_pine_labels&lt;/code&gt;: text annotations (signal labels, divergence callouts)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data_get_pine_tables&lt;/code&gt;: table cells (stats panels, multi-symbol scanners)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data_get_pine_boxes&lt;/code&gt;: price zones (supply, demand, order blocks)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data_get_pine_shapes&lt;/code&gt;: &lt;code&gt;plotshape&lt;/code&gt; and &lt;code&gt;plotchar&lt;/code&gt; markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Default behavior deduplicates and caps output (50 labels by default). Override with &lt;code&gt;verbose=true&lt;/code&gt; or &lt;code&gt;max_labels=N&lt;/code&gt; for cases where the agent needs the raw set.&lt;/p&gt;

&lt;p&gt;Why this matters: agents writing Pine Script could previously check whether the script &lt;em&gt;compiled&lt;/em&gt;, but couldn't verify whether the logic was &lt;em&gt;correct&lt;/em&gt;. Now the agent can write an indicator, compile it, and read the labels back to confirm the right bars were tagged. Without this, AI-assisted Pine work stops at "syntax is fine, hope you wrote what you meant."&lt;/p&gt;

&lt;p&gt;This also replaces the workflow that an agent would otherwise fall back to: screenshot the chart, run OCR over the image, hope the agent pieces together what the indicator drew. That path is slow (a screenshot round-trip plus OCR latency on every read), unreliable (overlapping labels and thin lines confuse the OCR), and expensive (chart images in the agent's context burn thousands of tokens before any reasoning happens). The structured readers skip all three problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Pane and Tab Navigation
&lt;/h2&gt;

&lt;p&gt;Pine indicators rarely live alone. A real chart layout might have three panes (price + RSI + volume) and four tabs, one per ticker. The original project assumed a single chart context. The fork adds eleven tools that make the layout addressable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pane_list&lt;/code&gt;, &lt;code&gt;pane_focus&lt;/code&gt;, &lt;code&gt;pane_set_layout&lt;/code&gt;, &lt;code&gt;pane_set_symbol&lt;/code&gt;, &lt;code&gt;pane_set_timeframe&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pane_read_batch&lt;/code&gt; (single call that reads across all visible panes)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tab_list&lt;/code&gt;, &lt;code&gt;tab_new&lt;/code&gt;, &lt;code&gt;tab_close&lt;/code&gt;, &lt;code&gt;tab_switch&lt;/code&gt;, &lt;code&gt;tab_switch_by_name&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;pane_read_batch&lt;/code&gt; was the practical motivator: agents that need to compare indicator values across panes used to need 5+ round-trips. The batch reader collapses that to one CDP call, which matters when the agent is iterating quickly.&lt;/p&gt;

&lt;p&gt;A non-obvious detail: pane focus needs a 300ms wait after &lt;code&gt;pane.focus()&lt;/code&gt; for &lt;code&gt;_activeChartWidgetWV&lt;/code&gt; to update, required since TV 3.1.0. The fork bakes this in so users don't have to discover it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Timeframe and Pattern Detection
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;data_get_multi_timeframe&lt;/code&gt; reads indicator values across a list of timeframes (W → D → 4H → 1H → 15m alignment) in one call. It saves and restores the original timeframe, so the agent gets multi-timeframe context without disrupting the user's view.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;data_detect_candlestick_patterns&lt;/code&gt; runs 17 classic patterns over raw OHLC bars: doji, hammer, hanging_man, inverted_hammer, shooting_star, marubozu, spinning_top, bullish_engulfing, bearish_engulfing, bullish_harami, bearish_harami, piercing_line, dark_cloud_cover, morning_star, evening_star, three_white_soldiers, three_black_crows. Each match returns the pattern name, a direction (bullish/bearish/neutral), and a strength score.&lt;/p&gt;

&lt;p&gt;The point of doing pattern detection natively, instead of via a Pine indicator overlay, is chart hygiene. Pattern detection informs the agent; the user never wants to see it plotted on the chart. Running it over OHLC bars directly means no Pine indicator gets added to the chart, no scripts get pushed to TV's Save dialog, and the user's chart stays clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;tv&lt;/code&gt; CLI
&lt;/h2&gt;

&lt;p&gt;The MCP surface is meant for AI agents. But the same operations are useful from a shell, and shipping both interfaces from the same code path (instead of building a separate CLI program) keeps them in sync.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tv&lt;/code&gt; ships 30 commands with 66 subcommands. A few examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tv quote get AAPL                          &lt;span class="c"&gt;# current quote&lt;/span&gt;
tv data ohlcv AAPL &lt;span class="nt"&gt;--tf&lt;/span&gt; 1H &lt;span class="nt"&gt;--bars&lt;/span&gt; 200      &lt;span class="c"&gt;# OHLCV bars&lt;/span&gt;
tv pine check ./my_indicator.pine          &lt;span class="c"&gt;# offline compile&lt;/span&gt;
tv pine smart_compile ./my_indicator.pine  &lt;span class="c"&gt;# add or update on chart&lt;/span&gt;
tv hotlist volume_gainers &lt;span class="nt"&gt;--country&lt;/span&gt; US     &lt;span class="c"&gt;# scanner&lt;/span&gt;
tv stream all                              &lt;span class="c"&gt;# poll-and-diff JSONL output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tv stream&lt;/code&gt; polls the chart and emits JSONL whenever something changes: a new bar, an indicator value update, a new label drawn. Useful for piping into tail-and-watch workflows or for sanity-checking a strategy as it runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Platform Launch
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tv_launch&lt;/code&gt; auto-detects native macOS, Linux, Windows, and Windows MSIX (Microsoft Store) installs of TradingView Desktop, resolves Windows paths correctly when invoked from WSL2, and handles the case where a binary refuses &lt;code&gt;--remote-debugging-port&lt;/code&gt; from a direct spawn. macOS Electron 38 is the worst offender there; the fix is to fall back to &lt;code&gt;open -a&lt;/code&gt; with the port flag in &lt;code&gt;--args&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The motivation for sinking this much code into launch behavior is operational: in a CI-like setup or a containerized agent loop, you want &lt;code&gt;tv_ensure&lt;/code&gt; to be a no-op when TV is already running and a clean launch when it isn't, on whichever OS the agent happens to be on. The original project assumed the user had TradingView running and never had to think about how it got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quiet Architectural Change
&lt;/h2&gt;

&lt;p&gt;One refactor: per-call &lt;code&gt;_deps&lt;/code&gt; dependency injection across ten core modules (&lt;code&gt;chart&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;drawing&lt;/code&gt;, &lt;code&gt;pane&lt;/code&gt;, &lt;code&gt;pine&lt;/code&gt;, &lt;code&gt;replay&lt;/code&gt;, &lt;code&gt;ui&lt;/code&gt;, &lt;code&gt;watchlist&lt;/code&gt;, &lt;code&gt;alerts&lt;/code&gt;, &lt;code&gt;capture&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Before: each module imported &lt;code&gt;connection.js&lt;/code&gt; directly. State lived in module globals. Tests had to monkey-patch &lt;code&gt;connection.js&lt;/code&gt; to swap CDP behavior.&lt;/p&gt;

&lt;p&gt;After: each function accepts a &lt;code&gt;_deps&lt;/code&gt; parameter, so the test harness passes in a mock CDP wrapper without touching globals. The &lt;code&gt;installCdpMocks&lt;/code&gt; and &lt;code&gt;mockEvaluateFromTable&lt;/code&gt; helpers in &lt;code&gt;tests/helpers/mock-cdp.js&lt;/code&gt; give every test an isolated environment.&lt;/p&gt;

&lt;p&gt;Practical result: 338 offline tests run in seconds without needing TradingView Desktop open. The previous test surface was much smaller because anything beyond a unit test required the real app running. The DI refactor unlocked the test count, which is what makes the 3.1.0 compat fixes safe to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Was Removed: &lt;code&gt;ui_evaluate&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The upstream had a tool called &lt;code&gt;ui_evaluate&lt;/code&gt; that accepted arbitrary JavaScript and ran it in the authenticated TradingView session. Anyone with MCP access to the agent had effective full read/write access to the user's TradingView account state: watchlists, alerts, saved scripts, anything the session could see.&lt;/p&gt;

&lt;p&gt;Dropped it. There's no use case for arbitrary JavaScript that isn't better served by a specific tool with a narrow boundary. If you need to read or modify something the existing tools don't cover, that's a missing tool, not a reason to expose the JS runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Doesn't Do
&lt;/h2&gt;

&lt;p&gt;The agent talks to your &lt;em&gt;running&lt;/em&gt; TradingView Desktop via CDP. There's no API spoofing, no auth bypass, no headless TV. If you don't have TV Desktop installed, the tool can't help you.&lt;/p&gt;

&lt;p&gt;TradingView's free tier covers most of the lifecycle tools. Some features need paid tiers (multi-pane, replay mode, second-resolution data, more indicators per chart). The tools wrap whatever the local TV exposes; they don't unlock features you don't have.&lt;/p&gt;

&lt;p&gt;It doesn't bypass anything. It doesn't get you data TradingView doesn't already give you. The improvement is the agent surface, not the data access.&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;git clone https://github.com/iliaal/tradingview-mcp
&lt;span class="nb"&gt;cd &lt;/span&gt;tradingview-mcp
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the server to &lt;code&gt;~/.claude/.mcp.json&lt;/code&gt; (or your MCP config). Launch TradingView with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt;. The README has the paste-into-Claude-Code one-liner that covers the agent-side wiring.&lt;/p&gt;

</description>
      <category>tradingview</category>
      <category>trading</category>
      <category>mcp</category>
      <category>showdev</category>
    </item>
    <item>
      <title>It's Alive! statgrab Returns After 20 Years</title>
      <dc:creator>Ilia Alshanetsky</dc:creator>
      <pubDate>Tue, 28 Apr 2026 21:15:29 +0000</pubDate>
      <link>https://dev.to/iliaa/its-alive-statgrab-returns-after-20-years-36e3</link>
      <guid>https://dev.to/iliaa/its-alive-statgrab-returns-after-20-years-36e3</guid>
      <description>&lt;p&gt;In 2005 I wrote a PHP binding for libstatgrab and pushed it to PECL. The extension took CPU, memory, disk I/O, network, process, and user statistics from a cross-platform C library and exposed them to PHP as plain functions. I moved on to other things, libstatgrab kept evolving, PHP went through three major versions, and the binding sat untouched. By 2020 you could not build it against PHP 7 without patches. By PHP 8 it was effectively gone.&lt;/p&gt;

&lt;p&gt;statgrab 2.0 brings it back. PHP 8.0 through 8.5, libstatgrab 0.92+, glibc Linux, musl, macOS, FreeBSD. The 2006 procedural API still works (&lt;code&gt;sg_cpu_percent_usage&lt;/code&gt;, &lt;code&gt;sg_memory_stats&lt;/code&gt;, &lt;code&gt;sg_diskio_stats&lt;/code&gt;), there is a modern OO surface (&lt;code&gt;Statgrab::cpu()&lt;/code&gt;, &lt;code&gt;Statgrab::memory()&lt;/code&gt;, &lt;code&gt;Statgrab::processes()&lt;/code&gt;), counters return as 64-bit &lt;code&gt;int&lt;/code&gt; instead of the 2006 stringified &lt;code&gt;%lld&lt;/code&gt;, and the BC bugs that were latent in the original (swapped page-stat keys, copy-pasted gid/egid fields, the flat &lt;code&gt;name_list&lt;/code&gt; for users) are fixed.&lt;/p&gt;

&lt;p&gt;A few things had to change to get there. One of them was upstreaming a memory leak fix to libstatgrab itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bring it back at all
&lt;/h2&gt;

&lt;p&gt;I do not pull old extensions forward by default. I learned why with &lt;a href="https://github.com/iliaal/lchash" rel="noopener noreferrer"&gt;&lt;code&gt;lchash&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;lchash is another extension I originally pushed to PECL in 2005: a string-keyed hash table for PHP, designed around the idea that PHP's array implementation, while general, carried ordering and bucket-reallocation overhead that pure key-value workloads did not need. Tighter memory footprint, faster lookup, simpler semantics ("first writer wins" like glibc &lt;code&gt;hsearch(ENTER)&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;I shipped a 1.0.0 modernization this week. Rebuilt the storage on top of &lt;a href="https://github.com/attractivechaos/klib" rel="noopener noreferrer"&gt;klib khash&lt;/a&gt;, got it green on PHP 7.4 through 8.5 NTS and ZTS, added a proper OO surface (&lt;code&gt;LcHash&lt;/code&gt; with &lt;code&gt;$obj[$key]&lt;/code&gt; dimension access), wrote a benchmark script and let it run.&lt;/p&gt;

&lt;p&gt;The numbers were not flattering. On a release build of PHP 8.4 NTS, glibc Linux x86_64, lchash takes 1.4x to 1.7x longer to insert and around 2x longer to look up than a native PHP array, at 10k, 100k, and 1M entries. That gap is structural. The PHP 7 array rewrite (Dmitry Stogov's packed-array work) and the 8.x JIT with inline caching produced a hash table the runtime treats as a first-class type, with opcode-level array-access specialization that no extension can match.&lt;/p&gt;

&lt;p&gt;The flip: lchash uses 40 to 80 percent of the memory PHP arrays do at the same entry count, because keys and values are stored as refcount-shared &lt;code&gt;zend_string&lt;/code&gt;s with no per-entry Bucket overhead. That makes the extension a real win for memory-tight workloads (a long-running CLI worker holding hundreds of thousands of small mappings), and it still has the legacy-compat and C-porting use cases it had in 2005. For general code, the answer is "just use a PHP array."&lt;/p&gt;

&lt;p&gt;I shipped lchash 1.0.0 anyway, with the benchmark table at the top of the README and the use cases honestly scoped. The lesson is not "do not revive things." It is: the revival has to be honest about what changed underneath. PHP arrays grew up. lchash now competes on memory, not speed, and the README says so before anyone has to find out.&lt;/p&gt;

&lt;p&gt;statgrab is not in that situation. PHP 8 arrays are not a substitute for cross-platform system stats. The choice today, for someone running PHP on a server who needs CPU, memory, or disk numbers, is still one of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Shell out to &lt;code&gt;w&lt;/code&gt;, &lt;code&gt;vmstat&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;, &lt;code&gt;ps&lt;/code&gt; and parse output that drifts between OS versions. &lt;code&gt;fork&lt;/code&gt;+&lt;code&gt;exec&lt;/code&gt; overhead per call.&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;/proc&lt;/code&gt; by hand. Linux-only, format keeps shifting between kernel releases, every file (&lt;code&gt;meminfo&lt;/code&gt;, &lt;code&gt;loadavg&lt;/code&gt;, &lt;code&gt;diskstats&lt;/code&gt;, &lt;code&gt;net/dev&lt;/code&gt;) has its own quirks.&lt;/li&gt;
&lt;li&gt;Run a separate stats daemon (collectd, telegraf, node_exporter) and hit it over a socket. Adds a process and a network hop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;libstatgrab is the right primitive for option 4: a single C library that handles the per-OS path internally (Linux &lt;code&gt;/proc&lt;/code&gt;, FreeBSD &lt;code&gt;kvm&lt;/code&gt;, macOS &lt;code&gt;host_*&lt;/code&gt; APIs) and exposes one typed surface. It has been in the Debian, Ubuntu, FreeBSD, and Homebrew package repositories for fifteen years. It just needed a PHP binding that worked on a current interpreter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What modernization meant in practice
&lt;/h2&gt;

&lt;p&gt;The 2006 binding was written against PHP 5, Zend 1, and 32-bit &lt;code&gt;long&lt;/code&gt;. Most of the rewrite is mechanical: convert TSRM-style globals, replace &lt;code&gt;Z_LVAL_PP&lt;/code&gt; patterns, switch to the typed parameter parsing macros. The non-mechanical parts were the BC quirks of the original API.&lt;/p&gt;

&lt;p&gt;Four bugs were latent in the 2006 release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stringified counters.&lt;/strong&gt; Memory totals, filesystem sizes, and CPU jiffies were returned as PHP strings. The reason was that 32-bit PHP could not hold a &lt;code&gt;uint64_t&lt;/code&gt;, so the binding called &lt;code&gt;snprintf("%lld", value)&lt;/code&gt; and shoved the string into a zval. Modern PHP runs on 64-bit &lt;code&gt;zend_long&lt;/code&gt;. The 2.0 release returns these as plain integers. Callers comparing against numeric thresholds (&lt;code&gt;if ($mem['total'] &amp;gt; 1_000_000_000)&lt;/code&gt;) now work correctly without an &lt;code&gt;intval()&lt;/code&gt; wrapping every read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swapped page-stat keys.&lt;/strong&gt; &lt;code&gt;sg_page_stats()&lt;/code&gt; returned &lt;code&gt;pages_in&lt;/code&gt; and &lt;code&gt;pages_out&lt;/code&gt; swapped. Anyone who used the function and noticed inverted memory pressure curves probably worked around it locally. Fixed in 2.0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;gid&lt;/code&gt; and &lt;code&gt;egid&lt;/code&gt; were copies of &lt;code&gt;uid&lt;/code&gt; and &lt;code&gt;euid&lt;/code&gt;.&lt;/strong&gt; A copy-paste in the 2006 process-stats handler. Anyone filtering by group ID had been getting user IDs back. Fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sg_user_stats()&lt;/code&gt; returned a flat list of usernames.&lt;/strong&gt; This one is a libstatgrab change, not just a binding fix. The old library exposed a &lt;code&gt;name_list&lt;/code&gt; array; the new library returns per-user records (login name, device, PID, login time, hostname). The new shape is strictly more useful. Callers reading &lt;code&gt;name_list&lt;/code&gt; migrate to reading &lt;code&gt;login_name&lt;/code&gt; from each record.&lt;/p&gt;

&lt;p&gt;The full BC catalog is in the README. None of these are surprises if you read the libstatgrab CHANGELOG; they are surprises only if you remember the 2006 binding from when you last used it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak I didn't expect
&lt;/h2&gt;

&lt;p&gt;While running the new test suite under AddressSanitizer, statgrab's process-exit path leaked memory. Several allocations from libstatgrab's internal structures were never freed when the library shut down.&lt;/p&gt;

&lt;p&gt;This is the kind of leak that does not matter for a long-running CLI process and does not show up in a typical request-response PHP cycle (the SAPI tears down the heap on each request). It matters for ASan-clean test runs, for valgrind-clean integration tests, and for anyone embedding libstatgrab in a long-lived process where shutdown order matters.&lt;/p&gt;

&lt;p&gt;I traced it into libstatgrab itself. The library has a &lt;code&gt;sg_shutdown()&lt;/code&gt; function but several globals were not on the cleanup path. I wrote the patch and submitted it upstream against libstatgrab 0.92.1; it is pending review. The libstatgrab release cadence is slow regardless, so the statgrab repo carries a vendored copy of libstatgrab 0.92.1 with the local patch documented under &lt;code&gt;vendor/libstatgrab/LOCAL_PATCHES.md&lt;/code&gt; in the meantime.&lt;/p&gt;

&lt;p&gt;If you build with &lt;code&gt;--with-statgrab=bundled&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;vendor/libstatgrab &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./configure &lt;span class="nt"&gt;--enable-static&lt;/span&gt; &lt;span class="nt"&gt;--disable-shared&lt;/span&gt; &lt;span class="nt"&gt;--without-ncurses&lt;/span&gt; &lt;span class="nt"&gt;--with-pic&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make&lt;span class="o"&gt;)&lt;/span&gt;
phpize
./configure &lt;span class="nt"&gt;--with-statgrab&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bundled
make
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting &lt;code&gt;statgrab.so&lt;/code&gt; has no runtime dependency on &lt;code&gt;libstatgrab.so&lt;/code&gt;. It links the patched copy in statically. For containerized or shared-hosting deployments this matters: you do not need to install &lt;code&gt;libstatgrab&lt;/code&gt; on the target system, and you do not pick up whatever version the package manager happens to have. Once libstatgrab cuts a release with the patch, the bundled tree gets dropped or pinned to the released tarball.&lt;/p&gt;

&lt;p&gt;The legal bookkeeping: vendored libstatgrab stays LGPL 2.1+, the extension code stays PHP-3.01. Static linking does not infect the extension because LGPL explicitly permits this with the standard provisions; the LICENSE files document both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-platform is the actual feature
&lt;/h2&gt;

&lt;p&gt;Most of the value is right here. The same PHP code that reads CPU usage on a glibc Linux box reads it on Alpine, on macOS, on FreeBSD. No conditional based on &lt;code&gt;PHP_OS&lt;/code&gt;, no different parser per platform, no surprise when an Alpine container behaves differently from the dev box because &lt;code&gt;/proc/meminfo&lt;/code&gt; formatting differs.&lt;/p&gt;

&lt;p&gt;libstatgrab does the per-OS adaptation in C, once, with tests. Linux uses &lt;code&gt;/proc&lt;/code&gt; and &lt;code&gt;sysfs&lt;/code&gt;. FreeBSD uses the &lt;code&gt;kvm&lt;/code&gt; interface. macOS uses &lt;code&gt;host_statistics()&lt;/code&gt;, &lt;code&gt;host_processor_info()&lt;/code&gt;, the BSD-style &lt;code&gt;sysctl&lt;/code&gt; tree. The binding is the same shape regardless. From the PHP side:&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;$cpu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sg_cpu_percent_usage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$mem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sg_memory_stats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sg_load_stats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those three calls return populated arrays on every supported OS. There is no "if Linux, do this; else do that" in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually do with it
&lt;/h2&gt;

&lt;p&gt;The thing PHP people kept asking me about, when statgrab existed in 2006, was health endpoints. A small JSON endpoint that returns CPU usage, memory pressure, and load average so the load balancer or the orchestrator can decide whether to send traffic. Today that is more often the job of a Prometheus exporter or a sidecar agent, but the in-process version still has its place: when the application itself wants to know its own state.&lt;/p&gt;

&lt;p&gt;Concrete examples that come up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A queue worker that throttles its concurrency when load average crosses a threshold.&lt;/li&gt;
&lt;li&gt;An admin dashboard inside a long-running CLI tool showing live disk I/O and network throughput.&lt;/li&gt;
&lt;li&gt;A test harness that asserts memory stays below a budget under a synthetic workload.&lt;/li&gt;
&lt;li&gt;A graceful-shutdown hook that waits for filesystem buffers to flush before exiting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each of those, shelling out is wrong (latency and parser fragility), and pulling in a stats daemon is overkill. statgrab fits the gap.&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;$mem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sg_memory_stats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sg_load_stats&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;$load&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'min1'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$mem&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'used'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$mem&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$worker&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;throttle&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;That is one library call per stat, no fork, no parsing. Same code on Linux, macOS, FreeBSD.&lt;/p&gt;

&lt;p&gt;The OO surface is a thin layer for callers who prefer a class:&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;$sg&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;Statgrab&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$sg&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;processes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Statgrab&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SORT_CPU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;$top&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$proc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$proc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'proc_name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$proc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'cpu_percent'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;PIE is the PHP Foundation's PECL successor and the recommended path on PHP 8.x:&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/statgrab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PECL still works for legacy installers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pecl &lt;span class="nb"&gt;install &lt;/span&gt;statgrab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From source against system libstatgrab (Debian, Ubuntu, macOS via Homebrew, FreeBSD pkg) is documented in the README. The &lt;code&gt;--with-statgrab=bundled&lt;/code&gt; path is for containerized environments and for picking up the unreleased leak fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitch
&lt;/h2&gt;

&lt;p&gt;One library call instead of forking a process. One typed surface instead of a per-OS parser. The same PHP code reading CPU, memory, and load on Linux, macOS, and FreeBSD without conditionals.&lt;/p&gt;

&lt;p&gt;That is what the 2006 extension was reaching for, on PHP and libstatgrab versions that were not quite there yet. Both have caught up. The binding is the missing piece.&lt;/p&gt;

&lt;p&gt;If you run PHP on a server and have ever shelled out to &lt;code&gt;w&lt;/code&gt; or parsed &lt;code&gt;/proc/meminfo&lt;/code&gt; by hand, give it a look.&lt;/p&gt;

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

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