<?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: Dusan Malusev</title>
    <description>The latest articles on DEV Community by Dusan Malusev (@malusev998).</description>
    <link>https://dev.to/malusev998</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F205284%2Fd1322214-595c-4b8e-aa0a-da79e0993e7a.jpeg</url>
      <title>DEV Community: Dusan Malusev</title>
      <link>https://dev.to/malusev998</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/malusev998"/>
    <language>en</language>
    <item>
      <title>ScyllaDB PHP Driver 1.4.0: the extension is pure C23 now</title>
      <dc:creator>Dusan Malusev</dc:creator>
      <pubDate>Mon, 13 Jul 2026 21:21:53 +0000</pubDate>
      <link>https://dev.to/malusev998/scylladb-php-driver-140-the-extension-is-pure-c23-now-4bbb</link>
      <guid>https://dev.to/malusev998/scylladb-php-driver-140-the-extension-is-pure-c23-now-4bbb</guid>
      <description>&lt;p&gt;The ScyllaDB PHP driver is not a C++ extension anymore. As of 1.4.0 it's pure C23, the &lt;code&gt;ZendCPP&lt;/code&gt; template layer we leaned on for the object embed and allocate pattern is deleted, and the build no longer needs a C++ compiler at all. Every hand-written &lt;code&gt;.cpp&lt;/code&gt; file is a &lt;code&gt;.c&lt;/code&gt; file now (71 of them), the descriptor generator emits &lt;code&gt;.c&lt;/code&gt;, and CMake builds with &lt;code&gt;c_std_23&lt;/code&gt; and nothing else. That's the biggest change to how this extension is built since we forked it for PHP 8.0.&lt;/p&gt;

&lt;p&gt;This is also the release where a plan I opened back in 2023 finally landed. PR #50 laid it out: rewrite the &lt;code&gt;src/Cluster&lt;/code&gt; directory to be more maintainable, use Zend Fast Argument Parsing, remove some memory allocations, and add &lt;code&gt;.stub.php&lt;/code&gt; files that generate the C headers so nobody has to hand-maintain Zend arginfo by hand. 1.4.0 is that plan finished, and a lot more that grew out of it.&lt;/p&gt;

&lt;p&gt;The things you'll actually feel: persistent session &lt;code&gt;connect()&lt;/code&gt; doesn't allocate a 200-character key string on every call anymore, and the minimum PHP is now 8.3 (8.2 is gone). Nothing in your application code changes, this is almost all under the surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The .stub.php Build
&lt;/h2&gt;

&lt;p&gt;The idea from PR #50 was small. Instead of writing &lt;code&gt;ZEND_BEGIN_ARG_INFO_EX&lt;/code&gt; blocks by hand and keeping them in sync with the actual method bodies, write the signature once in a &lt;code&gt;.stub.php&lt;/code&gt; file and generate the C arginfo from it. In v1.4.0 that's the whole build. There are 75 &lt;code&gt;.stub.php&lt;/code&gt; files now, and each one is just the PHP signature of the 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="c1"&gt;// src/Keyspace.stub.php&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;Keyspace&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;replicationClassName&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return array&amp;lt;string, mixed&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;replicationOptions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;hasDurableWrites&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return Table|false */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Table&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$types&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Aggregate&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="kc"&gt;false&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;At build time CMake runs &lt;code&gt;gen_stub.php&lt;/code&gt; (vendored from PHP 8.5's &lt;code&gt;build/gen_stub.php&lt;/code&gt;, with two small patches) and writes the &lt;code&gt;_arginfo.h&lt;/code&gt; next to the source. We stopped committing the generated headers, there are none in git anymore (75 of them got deleted from tracking), they're regenerated against whatever PHP version you're compiling against. That last part matters, PHP 8.3 and 8.5 do not emit identical arginfo, and now we don't pretend they do.&lt;/p&gt;

&lt;p&gt;The second half is class registration. The old &lt;code&gt;MINIT&lt;/code&gt; had one giant hand-maintained block, 98 &lt;code&gt;php_driver_define_*()&lt;/code&gt; calls in &lt;code&gt;src/php_driver.cpp&lt;/code&gt;, order-dependent and a merge-conflict magnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/php_driver.cpp (v1.3.17) - the old registration block&lt;/span&gt;
&lt;span class="n"&gt;php_driver_define_Value&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;php_driver_define_Numeric&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;php_driver_define_Bigint&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;php_driver_define_Cluster&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;php_driver_define_DefaultCluster&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;php_driver_define_ClusterBuilder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ... 92 more, in exactly this order or it breaks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of that is one line now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/php_scylladb.c - MINIT&lt;/span&gt;
&lt;span class="n"&gt;php_scylladb_class_registry_minit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every class self-registers with a descriptor via a &lt;code&gt;__attribute__((constructor))&lt;/code&gt; that appends it to a linked list when the &lt;code&gt;.so&lt;/code&gt; loads, before MINIT runs. At MINIT we walk the list, topologically sort by parent class, resolve dependencies, and register in the right order. If a parent is missing or you've made a cycle, it fails loudly at MINIT and names the class that broke it. (I wrote the whole descriptor mechanism up on its own if you want the deep dive, &lt;a href="https://dev.to/blog/self-registering-class-descriptors-deleting-minit-boilerplate-in-a-php-extension"&gt;self-registering class descriptors, deleting MINIT boilerplate in a PHP extension&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;A second generator, &lt;code&gt;gen_class_descriptor.php&lt;/code&gt; (about 515 lines), reads the same &lt;code&gt;.stub.php&lt;/code&gt; and emits the descriptor &lt;code&gt;.c&lt;/code&gt; with the object handlers wired up. So adding a class is now: write the stub, write the method bodies, list both files in the module CMake, and that's it. No touching &lt;code&gt;php_scylladb.c&lt;/code&gt;, no touching a central registration list.&lt;/p&gt;

&lt;p&gt;And this also let us delete 16 pure-interface &lt;code&gt;.cpp&lt;/code&gt; files (Cluster, Session, Statement, Future, Rows, and so on). They had no method bodies, just registration, so the generator makes them from the stub alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persistent Sessions Don't Allocate Anymore
&lt;/h2&gt;

&lt;p&gt;Here's the change most people running under PHP-FPM will actually feel. Persistent sessions cache the underlying &lt;code&gt;CassCluster&lt;/code&gt; in &lt;code&gt;EG(persistent_list)&lt;/code&gt; so it survives across requests. To find the cached entry, the old code built a key string out of every cluster setting, on every &lt;code&gt;build()&lt;/code&gt;, hit or miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Cluster/Builder.cpp (v1.3.17) - key built on every call&lt;/span&gt;
&lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hash_key_len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hash_key&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="n"&gt;PHP_DRIVER_NAME&lt;/span&gt; &lt;span class="s"&gt;":%s:%d:%d:%s:%d:%d:%d:%s:%s:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ZSTR_VAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;contact_points&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;load_balancing_policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_ZEND_STRING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;local_dc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="cm"&gt;/* ...28 fields... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;zval&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;le&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="n"&gt;PHP5TO7_ZEND_HASH_FIND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;EG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;persistent_list&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hash_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;hash_key_len&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="n"&gt;le&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;Z_TYPE_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;IS_RESOURCE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cluster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CassCluster&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;Z_RES_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="cm"&gt;/* cache hit - but we already paid for the string */&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 &lt;code&gt;spprintf&lt;/code&gt; into &lt;code&gt;emalloc&lt;/code&gt;'d memory, then a &lt;code&gt;zend_string_init&lt;/code&gt; (another allocation), then a hash over a 200-plus character string and a &lt;code&gt;memcmp&lt;/code&gt; to confirm. Every request. Even when the answer was sitting in the cache the whole time.&lt;/p&gt;

&lt;p&gt;v1.4.0 mixes the same fields into a &lt;code&gt;uint64_t&lt;/code&gt; FNV-1a fingerprint inline, no allocation, and looks it up as an integer key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Cluster/Builder.c (v1.4.0) - no allocation on the hot path&lt;/span&gt;
&lt;span class="n"&gt;zend_ulong&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;php_scylladb_cache_key_init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;php_scylladb_cache_key_mix_cstr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PHP_SCYLLADB_NAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;php_scylladb_cache_key_mix_zstr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;contact_points&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;php_scylladb_cache_key_mix_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cm"&gt;/* ...28 fields... */&lt;/span&gt;
&lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;zval&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;le&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zend_hash_index_find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;EG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;persistent_list&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;h&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="n"&gt;le&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;nullptr&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;Z_TYPE_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;IS_RESOURCE&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;Z_RES_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;php_le_php_scylladb_cluster&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cluster&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cluster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CassCluster&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;Z_RES_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="cm"&gt;/* cache hit - and we allocated nothing */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lookup is &lt;code&gt;zend_hash_index_find&lt;/code&gt; with an integer compare instead of &lt;code&gt;zend_hash_find&lt;/code&gt; walking a long string. The &lt;code&gt;mix_zstr&lt;/code&gt; and &lt;code&gt;mix_cstr&lt;/code&gt; helpers append a separator byte between fields so &lt;code&gt;("ab","c")&lt;/code&gt; and &lt;code&gt;("a","bc")&lt;/code&gt; can't collide into the same key. It's 64-bit FNV-1a, collisions across the few thousand entries a worker holds are not something you'll hit.&lt;/p&gt;

&lt;p&gt;One C23 detour worth mentioning here, wave 2 tried making the FNV offset and prime (and the default consistency) &lt;code&gt;static constexpr&lt;/code&gt; instead of &lt;code&gt;#define&lt;/code&gt;, it reads nicer and it's typed. It didn't stick. CI clang-tidy runs without &lt;code&gt;-std=c23&lt;/code&gt; and choked on &lt;code&gt;constexpr&lt;/code&gt;, so those went back to &lt;code&gt;#define&lt;/code&gt;. C23 is only as usable as your lint toolchain lets it be, so keep an eye on that if you're modernizing a C codebase, the compiler saying yes doesn't mean the rest of your tooling does.&lt;/p&gt;

&lt;p&gt;Net result, zero &lt;code&gt;zend_string&lt;/code&gt; allocations on the persistent-cache hot path. There's a memory-safety bonus too, the old &lt;code&gt;char*&lt;/code&gt; key was aliased between the cluster and the session, so &lt;code&gt;unset($cluster)&lt;/code&gt; before &lt;code&gt;$session-&amp;gt;prepare()&lt;/code&gt; could &lt;code&gt;efree&lt;/code&gt; it out from under the session (that was the use-after-free in issue #128). Integer keys don't alias, so that whole class of bug is just gone.&lt;/p&gt;

&lt;p&gt;You can see the difference on your own box. The measurement is simple, loop &lt;code&gt;build()+connect()&lt;/code&gt; with persistent sessions on and off, and watch the peak memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;bench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$persistent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&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="nb"&gt;memory_get_peak_usage&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;$t0&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hrtime&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="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&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;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&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;$session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cassandra&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;cluster&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;withContactPoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'127.0.0.1'&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;withPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9042&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;withPersistentSessions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$persistent&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;build&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;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Cassandra\SimpleStatement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT now() FROM system.local'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$session&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'per_op'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;hrtime&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;-&lt;/span&gt; &lt;span class="nv"&gt;$t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'mem_growth'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;memory_get_peak_usage&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;-&lt;/span&gt; &lt;span class="nv"&gt;$mem&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;$off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&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="c1"&gt;// persistent OFF: fresh CassCluster + key string every build&lt;/span&gt;
&lt;span class="nv"&gt;$on&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bench&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// persistent ON:  fingerprint lookup, no allocation on the hit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wall-clock moves a little, but the &lt;code&gt;mem_growth&lt;/code&gt; column is where the fingerprint change actually shows. The persistent path stops allocating a key string per call, so its peak barely moves while the non-persistent one climbs. The full script (prepared vs simple statements and batch inserts too) is in the repo under &lt;code&gt;benchmarks/persistent_sessions_bench.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also while we were in there, every &lt;code&gt;zend_parse_parameters&lt;/code&gt; call in the scalar types (Uuid, Inet, Blob, the numerics) moved to the FastZPP &lt;code&gt;Z_PARAM_*&lt;/code&gt; macros, and &lt;code&gt;getThis()&lt;/code&gt; became &lt;code&gt;ZEND_THIS&lt;/code&gt;. 153 parse sites converted, zero old-style &lt;code&gt;zend_parse_parameters&lt;/code&gt; left, CI greps for it so it stays that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Audits
&lt;/h2&gt;

&lt;p&gt;Two audits ran against the whole extension and both left tracking docs in &lt;code&gt;docs/&lt;/code&gt;. The memory-leak one worked through the extension and fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;borrowed-pointer leaks in the Builder setters&lt;/li&gt;
&lt;li&gt;missing &lt;code&gt;cass_future_free&lt;/code&gt; on error-return paths in &lt;code&gt;execute()&lt;/code&gt; and paging&lt;/li&gt;
&lt;li&gt;a missing &lt;code&gt;UNREGISTER_INI_ENTRIES()&lt;/code&gt; at shutdown&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_gc&lt;/code&gt; handlers that didn't delegate to &lt;code&gt;zend_std_get_gc&lt;/code&gt;, so cycles through object properties never got collected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the correctness and ZTS audit is the scarier list. One of them, in &lt;code&gt;ExecutionOptions&lt;/code&gt;, was a &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; that should have been &lt;code&gt;||&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/ExecutionOptions.cpp - CORR-001&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt;  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Z_TYPE_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;IS_OBJECT&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Z_TYPE_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;IS_OBJECT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
         &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;instanceof_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Z_OBJCE_P&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_policy&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;php_scylladb_retry_policy_ce&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the wrong operator a non-object &lt;code&gt;retry_policy&lt;/code&gt; skipped the type check and walked straight into &lt;code&gt;instanceof_function&lt;/code&gt; on garbage.&lt;/p&gt;

&lt;p&gt;Another one was &lt;code&gt;strncmp(name, "keyspace_name", name_length)&lt;/code&gt; used for schema lookups, which is a prefix match, so &lt;code&gt;"keyspace_name_2"&lt;/code&gt; matched &lt;code&gt;"keyspace_name"&lt;/code&gt;. Fixed to an exact-length &lt;code&gt;memcmp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then a batch of thread-safety fixes for people running ZTS. The log callback used &lt;code&gt;php_localtime_r&lt;/code&gt; and &lt;code&gt;PHP_EOL&lt;/code&gt; which aren't safe to touch from libuv's worker threads, those are POSIX &lt;code&gt;localtime_r&lt;/code&gt; and a literal &lt;code&gt;\n&lt;/code&gt; now, and the log lifecycle moved from per-thread init to process init.&lt;/p&gt;

&lt;p&gt;We also marked the 29 classes that hold a raw &lt;code&gt;Cass*&lt;/code&gt; pointer as &lt;code&gt;@not-serializable&lt;/code&gt;, because the default &lt;code&gt;unserialize&lt;/code&gt; would hand you an object with an uninitialized native handle, which is another use-after-free waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Much of This Was AI
&lt;/h2&gt;

&lt;p&gt;I'll be straight about this, a large part of 1.4.0 was written with AI (Claude Code), and it landed with an accuracy I did not expect going in. Not the design calls, those are mine, but the wide mechanical work is exactly what these tools are good at. Renaming 667 identifiers and 58 macros from &lt;code&gt;php_driver_*&lt;/code&gt; to &lt;code&gt;php_scylladb_*&lt;/code&gt; across 146 files without missing one. Converting 153 &lt;code&gt;zend_parse_parameters&lt;/code&gt; sites to FastZPP. Porting 71 files from C++ to C23. Doing that by hand is a week of tedium and a real chance of a typo that compiles fine and then breaks at runtime.&lt;/p&gt;

&lt;p&gt;The audits are the part that surprised me most. That correctness list up above was run as a checklist against the whole extension, every finding numbered, every one with a tracking doc in &lt;code&gt;docs/&lt;/code&gt;, a fix, and a test. The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;-that-should-be-&lt;code&gt;||&lt;/code&gt; in ExecutionOptions, a human doing a tired review at the end of the day walks straight past that. A pass that reads every single conditional and asks "is this the operator you meant" does not.&lt;/p&gt;

&lt;p&gt;But none of it shipped on trust, and that's the part that matters. Every change is behind the test suite, and the suite runs under ASan, UBSan and TSan in CI across 8.3, 8.4 and 8.5, NTS and ZTS. AI hallucinates, I've watched it confidently do something that makes no sense, so the rule was simple, if it can't pass the sanitizers and the Pest tests it doesn't merge, doesn't matter who or what wrote it. The accuracy is real, but it's accuracy inside a setup that catches the misses, not accuracy you take on faith.&lt;/p&gt;

&lt;h2&gt;
  
  
  Issue #108, Two Years Late
&lt;/h2&gt;

&lt;p&gt;Issue #108 sat open for nearly two years. The report was simple, passing an explicit &lt;code&gt;null&lt;/code&gt; as the &lt;code&gt;$options&lt;/code&gt; argument to &lt;code&gt;prepare()&lt;/code&gt;, &lt;code&gt;execute()&lt;/code&gt;, &lt;code&gt;executeAsync()&lt;/code&gt; or &lt;code&gt;prepareAsync()&lt;/code&gt; threw an &lt;code&gt;InvalidArgumentException&lt;/code&gt;, even though the signature says &lt;code&gt;array|ExecutionOptions|null&lt;/code&gt;. Under the hood &lt;code&gt;Z_PARAM_ZVAL&lt;/code&gt; hands you a non-null &lt;code&gt;IS_NULL&lt;/code&gt; zval, so the &lt;code&gt;if (options)&lt;/code&gt; guard walked into the type check and rejected it. The fix is one macro, &lt;code&gt;Z_PARAM_ZVAL_OR_NULL&lt;/code&gt;, so an explicit null collapses to &lt;code&gt;nullptr&lt;/code&gt; and behaves like leaving the argument off. That's the whole thing.&lt;/p&gt;

&lt;p&gt;Two years, for that. And while I was validating the fix under AddressSanitizer, a worse bug fell out of it, a cycle-collector use-after-free. The collection and value types keep their data in the C struct, but their &lt;code&gt;get_gc&lt;/code&gt; handlers fell back to &lt;code&gt;zend_std_get_gc&lt;/code&gt;, which rebuilds &lt;code&gt;object-&amp;gt;properties&lt;/code&gt; on every call. The collector runs &lt;code&gt;get_gc&lt;/code&gt; several times per pass, so it decremented one set of child refcounts and re-incremented a freshly rebuilt set, corrupting the counts and freeing a shared &lt;code&gt;CassDataType&lt;/code&gt; early (a crash on teardown). Set, Map, Collection, Tuple, UserTypeValue, the &lt;code&gt;Cassandra\Type\*&lt;/code&gt; classes, Decimal, Varint and the cluster Builder all got a real &lt;code&gt;zend_get_gc_buffer&lt;/code&gt; over their actual zvals. There's a &lt;code&gt;test-asan&lt;/code&gt; job now that runs the whole suite under ASan, red before the fix and green after, because a plain run doesn't reliably crash (the fault is heap-layout dependent).&lt;/p&gt;

&lt;p&gt;I'm not proud that #108 took me almost two years. A one-macro parameter fix should not sit open that long, and that is not the behavior of a maintainer who wants his project to survive and keep moving. I'm the only one on it, mostly in my free time, and for a long stretch that meant the backlog just sat there. People who hit #108 worked around it or walked.&lt;/p&gt;

&lt;p&gt;So that changes now, and AI is how. The same tooling that made this release possible, the mechanical refactors, the audits that found the operator bug, the ASan job that caught this GC crash, is what lets one person actually keep up with issues and features instead of drowning in them. The trunk-and-backport setup below is the other half of it. I'm going to be addressing things more than I have been.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Third Backend
&lt;/h2&gt;

&lt;p&gt;v1.4.0 adds a third driver backend, &lt;code&gt;scylla-rust&lt;/code&gt;, which is ScyllaDB's &lt;code&gt;cpp-rs-driver&lt;/code&gt; (the Rust core with a C-compatible API). You pick the backend at configure time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cmake"&gt;&lt;code&gt;&lt;span class="c1"&gt;# cmake/FindCPPDriver.cmake&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;PHP_SCYLLADB_BACKEND &lt;span class="s2"&gt;"scylla-cpp"&lt;/span&gt; CACHE STRING
    &lt;span class="s2"&gt;"C/C++ driver backend: cassandra | scylla-cpp | scylla-rust"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default is still &lt;code&gt;scylla-cpp&lt;/code&gt; (the ScyllaDB cpp-driver), so nothing changes unless you ask for it. &lt;code&gt;scylla-rust&lt;/code&gt; is experimental and opt-in, it's in the CI matrix but allowed to fail, and it's not in the released binaries. A few things don't work on it yet, &lt;code&gt;withMaxConnectionsPerHost()&lt;/code&gt; is a no-op and some schema-introspection calls come back empty (there's a compat shim that null-returns the six symbols the Rust driver doesn't implement, and the call sites null-check for it). If you want to try it, build it yourself with &lt;code&gt;scripts/compile-cpp-rs-driver.sh&lt;/code&gt;. If you don't, you'll never notice it exists.&lt;/p&gt;

&lt;p&gt;But don't ship it yet. &lt;code&gt;scylla-rust&lt;/code&gt; is not production-ready, it's a preview, treat it as something you benchmark and file bugs against, not something you point a live app at. The default stays &lt;code&gt;scylla-cpp&lt;/code&gt; for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  PHP Version Lifecycle
&lt;/h2&gt;

&lt;p&gt;Minimum PHP is 8.3 now, 8.2 is dropped. &lt;code&gt;composer.json&lt;/code&gt; went from &lt;code&gt;^8.2|^8.3|^8.4|^8.5&lt;/code&gt; to &lt;code&gt;^8.3|^8.4|^8.5&lt;/code&gt;. If you're stuck on 8.2, pin to a 1.3.x release (the tags aren't going anywhere), that's your line.&lt;/p&gt;

&lt;p&gt;The test matrix runs every supported version, both thread modes, against multiple backends:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;php&lt;/th&gt;
&lt;th&gt;thread modes&lt;/th&gt;
&lt;th&gt;backends tested&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8.3&lt;/td&gt;
&lt;td&gt;NTS, ZTS&lt;/td&gt;
&lt;td&gt;scylladb, cassandra, scylla-rust*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;NTS, ZTS&lt;/td&gt;
&lt;td&gt;scylladb, cassandra, scylla-rust*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8.5&lt;/td&gt;
&lt;td&gt;NTS, ZTS&lt;/td&gt;
&lt;td&gt;scylladb, cassandra, scylla-rust*&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* scylla-rust is allowed to fail and is not in the release artifacts.&lt;/p&gt;

&lt;p&gt;PHP 8.5 is a first-class target, the lint and clang-tidy reference build is &lt;code&gt;DebugPHP8.5NTS&lt;/code&gt; and the vendored &lt;code&gt;gen_stub.php&lt;/code&gt; comes from 8.5. For PHP 8.4 forward we shim &lt;code&gt;zend_register_internal_class_with_flags&lt;/code&gt; on older versions so the stub-generated registration compiles everywhere. The plan going forward is boring on purpose, track the current PHP release, drop the oldest once it goes security-only, and keep the arginfo generated per-version so a new PHP doesn't turn into a scramble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Branches Go Now
&lt;/h2&gt;

&lt;p&gt;The repo reorganized around this release, and if you track a branch instead of a release tag it'll affect you. The old &lt;code&gt;1.3.x&lt;/code&gt; branch is gone. There are three long-lived branches now: &lt;code&gt;trunk&lt;/code&gt; is the default and where all new work lands first, &lt;code&gt;v1.x&lt;/code&gt; is the maintenance line for the released 1.x series, and &lt;code&gt;v2.x&lt;/code&gt; is the next major, under active development and not shipped yet.&lt;/p&gt;

&lt;p&gt;Every PR opens against &lt;code&gt;trunk&lt;/code&gt;. Fixes reach the release lines by backport, not by targeting them directly. It's automated, you put a &lt;code&gt;backport/1.x&lt;/code&gt; or &lt;code&gt;backport/2.x&lt;/code&gt; label on the PR and the bot (&lt;code&gt;korthout/backport-action&lt;/code&gt;, SHA-pinned) cherry-picks the merged commit onto &lt;code&gt;v1.x&lt;/code&gt; or &lt;code&gt;v2.x&lt;/code&gt; and opens a &lt;code&gt;[Backport ...]&lt;/code&gt; PR. Clean cherry-pick and it's ready for review, a conflict and it still opens the PR with the markers and comments the manual &lt;code&gt;git&lt;/code&gt; steps. This #108 fix is the first thing going down that path to &lt;code&gt;v1.x&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your CI or your &lt;code&gt;composer.json&lt;/code&gt; points at the &lt;code&gt;1.3.x&lt;/code&gt; branch (the &lt;code&gt;dev-1.3.x&lt;/code&gt; style constraint), move it to a tagged release (&lt;code&gt;^1.4&lt;/code&gt;) or to &lt;code&gt;v1.x&lt;/code&gt;. The tags don't move, v1.3.17 and everything before it is still right where it was, it's only the branch that's gone. So this is the one breaking change in 1.4.0 that isn't code, it's where you pull from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start
&lt;/h2&gt;

&lt;p&gt;On PHP 8.3 or newer:&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;codelieutenant/scylla-driver
&lt;span class="c"&gt;# then build the extension for your PHP (CMake preset, or pecl/pie)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn on persistent sessions if you're running under FPM (&lt;code&gt;-&amp;gt;withPersistentSessions(true)&lt;/code&gt;), that's the path that got faster. Point the benchmark from earlier at your &lt;code&gt;127.0.0.1:9042&lt;/code&gt; and watch the peak-memory column more than the clock, the fingerprint change is about not allocating, not about raw CPU, so that's where it shows...&lt;/p&gt;

</description>
      <category>scylladb</category>
      <category>php</category>
      <category>c23</category>
      <category>database</category>
    </item>
    <item>
      <title>Streaming journald logs to the browser with SSE</title>
      <dc:creator>Dusan Malusev</dc:creator>
      <pubDate>Fri, 10 Jul 2026 21:23:40 +0000</pubDate>
      <link>https://dev.to/malusev998/streaming-journald-logs-to-the-browser-with-sse-3fd4</link>
      <guid>https://dev.to/malusev998/streaming-journald-logs-to-the-browser-with-sse-3fd4</guid>
      <description>&lt;p&gt;I got tired of SSHing into the box every time I shipped something, just to watch the logs come up. So I wanted a page in the admin panel where the lines scroll past as they happen, no dashboard, no Grafana, just the raw tail. The surprising part was how little I had to build for it, most of the pieces were already sitting on the server waiting for me to connect them.&lt;/p&gt;

&lt;p&gt;Here's the whole idea. The app writes JSON to stdout, systemd grabs that stdout and drops every line into the journal, and &lt;code&gt;journalctl&lt;/code&gt; can follow the journal and hand the lines back live. All three of those already exist on an Ubuntu box. So the "live log viewer" is really just me spawning &lt;code&gt;journalctl&lt;/code&gt; on the server and piping its output to the browser over an &lt;code&gt;EventSource&lt;/code&gt;, which is a lot less code than it sounds like.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;journald&lt;/code&gt; is boring and well understood. SSE is boring and well understood (it's been in browsers since about 2011). Nobody gets excited about either one on its own. But snap the two together and you get a real-time log tail with no agent, no log shipper, no vendor, and nothing new to keep alive. Two defaults meeting each other and pretending to be a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Systemd thing
&lt;/h2&gt;

&lt;p&gt;People have opinions about systemd. Some of them are that you should avoid every part of it that you can, run your own supervisor, ship logs somewhere with your own daemon, and treat journald as a thing to route around. That's a fine hobby if you have the time for it. I don't. The box boots, &lt;code&gt;systemd&lt;/code&gt; starts my unit, and when the unit writes to stdout the line ends up in the journal without me configuring anything. Being a purist here costs real hours and buys me a philosophy. Being pragmatic costs nothing and buys me a log tail.&lt;/p&gt;

&lt;p&gt;So this is the pragmatic path. If you're on a distro where journald is the default (Ubuntu, Debian, Fedora, most of them now) the setup below is basically free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting logs into the journal
&lt;/h2&gt;

&lt;p&gt;Here is the part most people overcomplicate. You do not "set up journald". You do not open a socket to it or pull in a library. You write to standard output, and if your process is a systemd service, systemd is already reading your stdout and stderr and writing every line into the journal tagged with your unit name.&lt;/p&gt;

&lt;p&gt;My unit is about as plain as it gets:&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="c"&gt;# /etc/systemd/system/dusanmalusev-dev.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;dusanmalusev.dev&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/node /srv/dusanmalusev-dev/build/index.js&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/srv/dusanmalusev-dev&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;always&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;www-data&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;NODE_ENV=production&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;// src/lib/server/logger.ts&lt;br&gt;
const stdout = pino.destination({ dest: 1, sync: false });&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
So each journal entry's `MESSAGE` field is a full JSON object, `{"level":30,"time":1720...,"msg":"request completed",...}`. That matters later, because it means I don't need journald's own timestamp or metadata. My structured fields are already inside the message.

Why bother with the journal at all instead of a file? A few reasons that add up. The journal is structured (every entry has fields you can query on, not just a blob of text), it rotates itself by size and age so I never touch logrotate, it survives across restarts, and I can already ask it questions from the shell without grep gymnastics:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
journalctl -u dusanmalusev-dev.service -f          # follow live&lt;br&gt;
journalctl -u dusanmalusev-dev.service --since "1 hour ago"&lt;br&gt;
journalctl -u dusanmalusev-dev.service -p err      # errors and worse&lt;br&gt;
journalctl -u dusanmalusev-dev.service -o json     # structured out&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
That last flag is the one that unlocks everything else. `-o json` (and its lighter sibling `-o cat`) turn the journal into something a program can parse, not just something a human can read.

## reading the journal back out

To follow the log live I spawn `journalctl` and read its stdout line by line. The flags that matter are `-f` (follow), `-n 0` (don't dump history, I only want new lines), and `-o cat` (print just the `MESSAGE` field). Since my MESSAGE is already pino JSON, `-o cat` hands me exactly the JSON I want with no journal wrapper to peel off.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
typescript&lt;br&gt;
// src/lib/server/admin/logs.ts (followLogs)&lt;br&gt;
const child = spawn('journalctl', [&lt;br&gt;
    '-u', unit,&lt;br&gt;
    '-o', 'cat',   // just the MESSAGE field, which is our pino JSON&lt;br&gt;
    '-f',          // follow&lt;br&gt;
    '-n', '0',     // no backlog, only new lines from now&lt;br&gt;
    '--no-pager'&lt;br&gt;
]);&lt;/p&gt;

&lt;p&gt;let buf = '';&lt;br&gt;
child.stdout?.setEncoding('utf8');&lt;br&gt;
child.stdout?.on('data', (chunk: string) =&amp;gt; {&lt;br&gt;
    buf += chunk;&lt;br&gt;
    let idx;&lt;br&gt;
    while ((idx = buf.indexOf('\n')) !== -1) {&lt;br&gt;
        const line = buf.slice(0, idx);&lt;br&gt;
        buf = buf.slice(idx + 1);&lt;br&gt;
        if (!line) continue;&lt;br&gt;
        const parsed = parseLine(line);&lt;br&gt;
        if (!parsed) continue;&lt;br&gt;
        const ev = toStreamEvent(parsed);&lt;br&gt;
        if (passesFilter(ev, filter)) onEvent(ev);&lt;br&gt;
    }&lt;br&gt;
});&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The buffering there is the one thing you can't skip. `data` events don't arrive on line boundaries, a single chunk can be half a line, or three lines, or a line plus a fragment of the next. So I accumulate into `buf` and only cut on `\n`, keeping whatever partial line is left for the next chunk. Get this wrong and you'll spend an afternoon wondering why one log line in fifty is truncated JSON.

Parsing is forgiving on purpose. Most lines are pino JSON, but startup banners and stray stderr traces aren't, and I'd rather show those than drop them:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
typescript&lt;br&gt;
// src/lib/server/admin/logs.ts (parseLine)&lt;br&gt;
function parseLine(line: string): LogLine | null {&lt;br&gt;
    try {&lt;br&gt;
        const obj = JSON.parse(line) as Record;&lt;br&gt;
        const time = typeof obj.time === 'number' ? obj.time : Date.now();&lt;br&gt;
        const level = typeof obj.level === 'number' ? obj.level : 30;&lt;br&gt;
        const msg = typeof obj.msg === 'string' ? obj.msg : '';&lt;br&gt;
        return { time, level, levelName: LEVEL_NAME[level] ?? String(level), msg, raw: obj };&lt;br&gt;
    } catch {&lt;br&gt;
        // Non-JSON line (a stderr trace or startup banner). Surface it as&lt;br&gt;
        // info-level with the raw text so the user still sees it.&lt;br&gt;
        const trimmed = line.trim();&lt;br&gt;
        if (!trimmed) return null;&lt;br&gt;
        return { time: Date.now(), level: 30, levelName: 'info', msg: trimmed, raw: { msg: trimmed } };&lt;br&gt;
    }&lt;br&gt;
}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
The filtering (`level`, `contains`) runs server-side before anything goes out on the wire. A client watching only errors shouldn't make the server serialize and send every debug line just to have the browser throw it away.

## the SSE endpoint

Server-Sent Events is the right transport here and WebSockets would be the wrong one. The data goes one direction (server to browser), it's text, and I want automatic reconnection without writing any of it. That's the exact shape SSE was designed for. The wire format is almost insultingly simple, you write `event: &amp;lt;name&amp;gt;` and `data: &amp;lt;json&amp;gt;` and a blank line, and the browser's `EventSource` does the rest.

In SvelteKit the endpoint is a `GET` that returns a `ReadableStream` with the `text/event-stream` content type. `followLogs` pushes each parsed line into the stream as a `log` event:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
typescript&lt;br&gt;
// src/routes/admin/logs/stream/+server.ts&lt;br&gt;
export const GET: RequestHandler = async (event) =&amp;gt; {&lt;br&gt;
    requireAdmin(event);&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const encoder = new TextEncoder();
let stopFollow: (() =&amp;gt; void) | null = null;
let heartbeat: ReturnType&amp;lt;typeof setInterval&amp;gt; | null = null;

const stream = new ReadableStream&amp;lt;Uint8Array&amp;gt;({
    start(controller) {
        const send = (name: string, payload: unknown) =&amp;gt; {
            try {
                controller.enqueue(
                    encoder.encode(`event: ${name}\ndata: ${JSON.stringify(payload)}\n\n`)
                );
            } catch {
                /* controller closed */
            }
        };

        const handle = followLogs(
            { level, contains },
            (ev) =&amp;gt; send('log', ev),
            (err) =&amp;gt; send('error', { message: err.message })
        );
        stopFollow = handle.stop;

        send('ready', { source: handle.source, available: handle.available });

        // keeps idle-killing proxies (nginx / Cloudflare) from cutting us off
        heartbeat = setInterval(() =&amp;gt; {
            try {
                controller.enqueue(encoder.encode(`: ping\n\n`));
            } catch {
                /* noop */
            }
        }, 25_000);
    },
    cancel() {
        stopFollow?.();
        if (heartbeat) clearInterval(heartbeat);
    }
});

return new Response(stream, {
    headers: {
        'content-type': 'text/event-stream; charset=utf-8',
        'cache-control': 'no-store, no-transform',
        'x-accel-buffering': 'no',
        connection: 'keep-alive'
    }
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;};&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Three lines in there are the difference between "works on my laptop" and "works behind nginx", and I'll come back to them.

The `cancel()` on the stream is the part you can't forget. When the browser tab closes, the `ReadableStream` is cancelled, and if I don't kill the `journalctl` child there, every disconnect leaks a process. On a long-running box that's how you end up with two hundred orphaned `journalctl -f` processes and a confused future you. `stopFollow()` sends `SIGTERM` to the child, done.

## the browser side

The client is the smallest piece. `new EventSource(url)`, then listen for the named events. `EventSource` reconnects on its own if the connection drops, which is most of why I picked SSE over rolling something on `fetch`.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
typescript&lt;br&gt;
// src/routes/admin/logs/+page.svelte&lt;br&gt;
let es: EventSource | null = null;&lt;/p&gt;

&lt;p&gt;function connect() {&lt;br&gt;
    if (es) es.close();&lt;br&gt;
    es = new EventSource(streamUrl());&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;es.addEventListener('ready', (ev) =&amp;gt; {
    const payload = JSON.parse((ev as MessageEvent).data);
    liveSource = payload.source;
    connected = payload.available;
});

es.addEventListener('log', (ev) =&amp;gt; {
    if (paused) return;
    const e = JSON.parse((ev as MessageEvent).data) as LogEvent;
    events = [e, ...events].slice(0, linesCap);   // newest first, capped
});

es.addEventListener('error', (ev) =&amp;gt; {
    const payload = JSON.parse((ev as MessageEvent).data ?? '{}');
    if (payload.message) streamError = payload.message;
    connected = false;
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
`events = [e, ...events].slice(0, linesCap)` prepends the newest line and caps the array, so the DOM never grows without bound on a chatty service. Everything else is just rendering. Level filter and search box rebuild the URL (`?level=warn&amp;amp;q=timeout`), close the old `EventSource`, and open a new one, so the filtering happens server-side and the browser only ever receives lines it's going to show.

## the parts that actually bite you

The happy path above is maybe forty lines. The three things that cost me real time were all environmental, not code.

**Permissions.** By default the app user can't read the journal. `journalctl` runs, returns nothing or errors, and you assume your spawn is broken when it isn't. The fix is one group, the user your service runs as needs to be in `systemd-journal`:

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
bash&lt;br&gt;
usermod -aG systemd-journal www-data&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


I only figured this out fast because I catch the error and translate it instead of swallowing it, so the viewer says `Permission denied reading the journal, the app user needs to be in the systemd-journal group` instead of just going quiet. Past-me thanking past-me for that one.

**Proxy buffering.** nginx buffers proxied responses by default, which for a normal request is what you want and for an event stream means the browser sees nothing for thirty seconds and then a wall of lines. The `x-accel-buffering: no` header turns nginx buffering off for that one response, and `cache-control: no-transform` stops anything in the middle from trying to be clever. Without those two, SSE behind nginx just looks broken.

**Idle timeouts.** A quiet service might not emit a line for minutes, and proxies love to kill connections that go quiet. The `: ping\n\n` every 25 seconds is an SSE comment (any line starting with `:` is ignored by the client), it carries no data and exists only to keep the pipe warm. Cheap insurance.

None of those three are journald problems or SSE problems. They're deployment problems, the kind you only meet once the thing leaves your laptop.

## the short version

The whole path is four hops. The app writes JSON to stdout, systemd puts every line into the journal under your unit, `journalctl -f -o cat` follows that journal and hands the lines back, and an SSE endpoint pipes them to an `EventSource` in the browser. On the code side that's a `spawn`, a `ReadableStream`, and about ten lines of client. Everything expensive (structured logs, rotation by size and age, retention, querying) was already done for you by something that booted before your app did.

So if you're already on a systemd distro, this is close to free. Write to stdout, add the service user to `systemd-journal`, point `journalctl -f -o cat` at your unit, and pipe it out over SSE. That's the whole trick, and most of it isn't yours to build.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>journald</category>
      <category>sse</category>
      <category>sveltekit</category>
      <category>linux</category>
    </item>
    <item>
      <title>JWT is a scam and your app doesn't need it</title>
      <dc:creator>Dusan Malusev</dc:creator>
      <pubDate>Sat, 23 May 2026 17:21:14 +0000</pubDate>
      <link>https://dev.to/malusev998/jwt-is-a-scam-and-your-app-doesnt-need-it-3g0a</link>
      <guid>https://dev.to/malusev998/jwt-is-a-scam-and-your-app-doesnt-need-it-3g0a</guid>
      <description>&lt;p&gt;I am tired of pretending JWT is fine.&lt;/p&gt;

&lt;p&gt;It isn't. It's a cargo cult. It solves a problem your app almost certainly does not have, it creates four or five problems your app definitely does have, and a generation of backend developers has been bullied into shipping it because some blog post in 2014 said "stateless" like it was a virtue instead of a tradeoff. Every Laravel app I started with &lt;code&gt;tymondesigns/jwt-auth&lt;/code&gt; I eventually ripped it out of. Every JWT-based system I've audited has the same broken revocation story, the same useless refresh dance, and the same client codebase that decodes the payload and trusts it. My friend Dusan Mitrovic &lt;a href="https://dusanmitrovic.rs/blog/post/2020-10-12/My-disenchantment-with-JWT-based-Authentication" rel="noopener noreferrer"&gt;wrote about this in 2020&lt;/a&gt;. Six years later, people are still shipping the same mistakes, so here we go again.&lt;/p&gt;

&lt;p&gt;If you're building a web app, a mobile app, or a first-party API: &lt;strong&gt;JWT is the wrong default and you should stop reaching for it.&lt;/strong&gt; A row in Postgres with a bearer token in front of it is faster, simpler, and strictly more secure. The rest of this post is me showing my work.&lt;/p&gt;

&lt;h2&gt;
  
  
  what JWT actually is, and what the pitch was
&lt;/h2&gt;

&lt;p&gt;A JWT is three base64url segments — a header, a JSON payload, a signature. The signature is either an HMAC or an RSA/ECDSA signature. The payload usually holds a user id, an &lt;code&gt;iat&lt;/code&gt;, an &lt;code&gt;exp&lt;/code&gt;, a &lt;code&gt;jti&lt;/code&gt;, maybe some scopes.&lt;/p&gt;

&lt;p&gt;The pitch is: the server signs it, the client carries it, every subsequent request only needs a signature verification — no database round-trip. &lt;strong&gt;Stateless authentication.&lt;/strong&gt; That is the entire value proposition. Strip that one property away and JWT is just an opaque token wearing a costume.&lt;/p&gt;

&lt;p&gt;So let's strip it away. It takes one question.&lt;/p&gt;

&lt;h2&gt;
  
  
  you cannot invalidate a JWT. you just can't.
&lt;/h2&gt;

&lt;p&gt;How do you log a user out before &lt;code&gt;exp&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;You can't. That's the answer. The token is valid until it expires, full stop. The only way to invalidate it is to store the &lt;code&gt;jti&lt;/code&gt; server-side in a revocation list and check that list &lt;strong&gt;on every single request&lt;/strong&gt;. Which is a database lookup. Which is the thing JWT was supposed to let you skip. Congratulations, you've reinvented sessions, badly.&lt;/p&gt;

&lt;p&gt;So you pick one of two losing moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't invalidate.&lt;/strong&gt; A compromised token is valid until &lt;code&gt;exp&lt;/code&gt;. And because nobody wants to "force users to log in again" — I have personally seen 1-year tokens, 5-year tokens, and a 10-year token in production at a company I won't name — the attacker owns the account for that entire window. Your "log out everywhere" button is a lie. Your password reset doesn't actually kick anyone out. You can rotate the signing key, sure, and log out &lt;strong&gt;every user on the platform&lt;/strong&gt; to fix one breach. Beautiful design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintain a revocation list.&lt;/strong&gt; Now every request does signature verification &lt;em&gt;plus&lt;/em&gt; a database or Redis lookup. You're paying both costs. You took on all the complexity of JWT to keep all the cost of sessions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no third option. There has never been a third option. Anyone who tells you otherwise is selling something.&lt;/p&gt;

&lt;h2&gt;
  
  
  refresh tokens are a confession
&lt;/h2&gt;

&lt;p&gt;The standard panic response to "long-lived JWTs are dangerous" is: use short-lived access tokens and a refresh token. Read that sentence carefully. It says &lt;em&gt;the thing we sold you is unsafe, so here is a second thing to patch around it&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;What you now ship, on every client:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;attach the access token to every request&lt;/li&gt;
&lt;li&gt;detect a 401 or near-expiry&lt;/li&gt;
&lt;li&gt;call &lt;code&gt;/auth/refresh&lt;/code&gt; with the refresh token&lt;/li&gt;
&lt;li&gt;retry the original request with the new access token&lt;/li&gt;
&lt;li&gt;handle the refresh failing (forced logout, redirect, queue replay)&lt;/li&gt;
&lt;li&gt;secure storage for two tokens instead of one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And on the server you store the refresh token in the database for revocation. So you are &lt;strong&gt;already stateful&lt;/strong&gt;. You built all of this — across web, iOS, Android, and every third party that integrates with you — to avoid the thing you ended up doing anyway. Every client team now spends a sprint on token plumbing instead of shipping product. For what? Because some JS developer in 2014 thought signed JSON was elegant?&lt;/p&gt;

&lt;p&gt;A single opaque token, looked up in Redis with Postgres as the backing store, gives you the same security in one line of middleware. No refresh. No second token. No retry loop. Nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  the per-request cost is real and people lie about it
&lt;/h2&gt;

&lt;p&gt;The standard handwave is that signature verification is "basically free". It is not.&lt;/p&gt;

&lt;p&gt;Rough orders of magnitude per request on a modern x86 box:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;Verification time&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HS256&lt;/td&gt;
&lt;td&gt;~5 µs&lt;/td&gt;
&lt;td&gt;HMAC-SHA256, symmetric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RS256&lt;/td&gt;
&lt;td&gt;~80–150 µs&lt;/td&gt;
&lt;td&gt;2048-bit RSA, what most issuers ship&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ES256&lt;/td&gt;
&lt;td&gt;~40–80 µs&lt;/td&gt;
&lt;td&gt;NIST P-256 ECDSA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis GET&lt;/td&gt;
&lt;td&gt;~100–300 µs&lt;/td&gt;
&lt;td&gt;localhost, single round-trip&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;RS256 verification is in the &lt;strong&gt;same order of magnitude as a Redis lookup&lt;/strong&gt;. And you still have to parse JSON, validate &lt;code&gt;exp&lt;/code&gt;, validate &lt;code&gt;iss&lt;/code&gt;, validate &lt;code&gt;aud&lt;/code&gt;, walk the JWKS cache for the right key, and allocate a few objects the GC has to clean up later. Multiply by your request rate.&lt;/p&gt;

&lt;p&gt;"JWT saves you database hits" turns out to be mostly false the moment you measure it. An opaque token check is hash, &lt;code&gt;GET&lt;/code&gt; from Redis, done — with an in-process LRU in front if you actually care. You're not saving compute by going JWT. You are spending more of it, on something less safe, that you can't revoke.&lt;/p&gt;

&lt;h2&gt;
  
  
  the frontend "verification" nobody has ever shipped
&lt;/h2&gt;

&lt;p&gt;The asymmetric-JWT pitch had one more leg: the server publishes a public key, the client (or a gateway, or a third party) verifies the signature locally, and the auth server stays out of the request path. If you actually do this, you do save round-trips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Almost no one does this.&lt;/strong&gt; Large platforms with dedicated identity teams do. The Laravel app you're shipping next month does not. WebCrypto wasn't usable when JWT became fashionable, so frontends shipped &lt;code&gt;atob(token.split('.')[1])&lt;/code&gt;, parsed the JSON, trusted whatever was inside, and called it a day. When the access token expired the next API call returned 401, the SPA bounced to &lt;code&gt;/login&lt;/code&gt;, and the server got hit anyway. The "stateless" benefit existed only on a slide deck.&lt;/p&gt;

&lt;p&gt;And here's the part that should make you angry: if you hand a JWT to a third-party integrator and they skip the public-key verification — and they will, because everyone does — every single one of their requests becomes a request through your auth backend the long way around. &lt;strong&gt;You wear the cost of their laziness, forever.&lt;/strong&gt; With opaque tokens this is just… how it works. No mismatch, no hidden tax, no "did they implement the checks correctly" question to lose sleep over.&lt;/p&gt;

&lt;h2&gt;
  
  
  encrypted JWT (JWE) is even more nonsensical
&lt;/h2&gt;

&lt;p&gt;If you're encrypting the payload because it contains sensitive data, you've also decided the client can't read it. So what is it doing in the token? The client is going to call &lt;code&gt;/api/me&lt;/code&gt; to render the UI anyway. Put the data behind that endpoint and have the token reference it. JWE solves a problem you invented by stuffing user data into a credential.&lt;/p&gt;

&lt;p&gt;And while we're here: if the JWT payload becomes the source of truth for "who is this user", the user updates their email, their role gets revoked, their plan gets downgraded — and your app keeps reading stale values from the token until the next refresh. The user files a support ticket because the app "isn't updating". You'll fix it by fetching the user from the database on every request. You're stateful again. You were always going to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  "just put the JWT in an httpOnly cookie"
&lt;/h2&gt;

&lt;p&gt;This is the suggestion that finally gives the game away.&lt;/p&gt;

&lt;p&gt;The advice goes: don't put the JWT in &lt;code&gt;localStorage&lt;/code&gt; because XSS will steal it — put it in an &lt;code&gt;httpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite&lt;/code&gt; cookie so JavaScript can't touch it. Read that sentence one more time. You have just described &lt;strong&gt;a session cookie&lt;/strong&gt;. The browser attaches it automatically, the server reads it on every request, the client can't see what's inside.&lt;/p&gt;

&lt;p&gt;The only thing distinguishing it from a normal session cookie is that the bytes inside happen to be a signed JWT instead of a random ID. And since the browser can't read those bytes, the "you can decode it client-side" feature — already a feature nobody used — is now physically impossible. You have kept every line of JWT complexity (signing, expiry, refresh, JWKS) and given up the &lt;strong&gt;only&lt;/strong&gt; property that ever made JWT different from a session.&lt;/p&gt;

&lt;p&gt;That's it. That's the whole house of cards. If you're putting a JWT in an httpOnly cookie, you are running a stateful session with extra cryptography. Stop. Just run the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  "no system is stateless" — please stop pretending
&lt;/h2&gt;

&lt;p&gt;Your system has users. Your system has sessions. Your system has rate limits, audit logs, permissions, billing state, devices, account lockouts, abuse signals, feature flags. &lt;strong&gt;Every one of those is server-side state.&lt;/strong&gt; Reading a session row alongside the user row in the same query costs you nothing. The architectural prize of "statelessness" only matters if every other thing in your request path is also stateless — and in a real app, it isn't and it never will be.&lt;/p&gt;

&lt;p&gt;The Okta talk &lt;a href="https://www.youtube.com/watch?v=GdJ0wFi1Jyo" rel="noopener noreferrer"&gt;"Why JWTs Are Bad for Authentication"&lt;/a&gt; makes this point at length, and Okta is a company that sells you JWT for a living. When the vendor tells you to stop using their flagship pattern for first-party browser apps, maybe listen.&lt;/p&gt;

&lt;h2&gt;
  
  
  what to ship instead
&lt;/h2&gt;

&lt;p&gt;For a first-party web app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;httpOnly, Secure, SameSite=Lax session cookie.&lt;/strong&gt; A 256-bit random opaque ID. Done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions table.&lt;/strong&gt; &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;last_seen_at&lt;/code&gt;, &lt;code&gt;user_agent&lt;/code&gt;, &lt;code&gt;ip&lt;/code&gt;, &lt;code&gt;revoked_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis in front&lt;/strong&gt; keyed by the session ID hash. TTL ~60s or invalidate on write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logout:&lt;/strong&gt; &lt;code&gt;UPDATE sessions SET revoked_at = NOW() WHERE id = $1&lt;/code&gt;, plus &lt;code&gt;DEL&lt;/code&gt; in Redis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force-logout all devices:&lt;/strong&gt; the same UPDATE with &lt;code&gt;user_id = $1&lt;/code&gt;. The feature your JWT app pretends to have, this app actually has.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an API consumed by mobile or by integrators:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opaque bearer tokens.&lt;/strong&gt; GitHub-style &lt;code&gt;xxx_&lt;/code&gt; prefix so they're greppable in logs and revocable by secret-scanning services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same table.&lt;/strong&gt; Add &lt;code&gt;scopes&lt;/code&gt;, &lt;code&gt;expires_at&lt;/code&gt;, &lt;code&gt;last_used_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An admin panel&lt;/strong&gt; to list, revoke, regenerate, expire. The thing you should have built for your JWT system but didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;.&lt;/strong&gt; Document it once. Every HTTP client on Earth speaks this already.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get everything JWT promised — issuance, revocation, expiry, scopes, audit — without the signing, the JWKS rotation, the refresh dance, the encoding gotchas, and the CVE class that begins with &lt;code&gt;alg: none&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  "but I'm building an OAuth 2 server"
&lt;/h2&gt;

&lt;p&gt;Great. OAuth 2 is a delegation protocol — it does not require JWT. The spec says "access token" and leaves the format opaque on purpose. Build an OAuth 2 server with opaque tokens and an introspection endpoint and you are 100% compliant. The only time you actually &lt;em&gt;need&lt;/em&gt; JWT-shaped access tokens is when your resource servers are operated by parties who genuinely cannot call your introspection endpoint on every request. That is a real use case. It is not your SaaS dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it isn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not "JWT is broken".&lt;/strong&gt; The cryptography is fine. The &lt;code&gt;tymondesigns/jwt-auth&lt;/code&gt; package is fine. The concept of using JWT as your app's session is what's broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not blanket opposition to signed tokens.&lt;/strong&gt; Short-lived signed JWTs for service-to-service calls inside a trusted mesh, or as ID tokens in OIDC, are legitimate. Both are narrow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not a claim opaque tokens are free.&lt;/strong&gt; You pay for the Redis lookup. You were going to pay for it the moment you wanted to log a user out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not apply to true federation.&lt;/strong&gt; If you're Okta, Auth0, or running an identity provider whose tokens are consumed by hundreds of resource servers you don't operate, JWT earns its keep. You are not them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not a license to hand-roll session cookies.&lt;/strong&gt; Use your framework's session driver. The whole reason this is boring is that the framework already solved it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're starting an app today, default to a session cookie or an opaque bearer token in a database, cached in Redis. Reach for JWT only when you can point at a federation requirement that actually needs it. I have built and rebuilt this stack enough times to be sure. JWT was the most expensive lesson I learned twice.&lt;/p&gt;

</description>
      <category>auth</category>
      <category>security</category>
      <category>architecture</category>
      <category>jwt</category>
    </item>
    <item>
      <title>Self-registering class descriptors: deleting MINIT boilerplate in a PHP extension</title>
      <dc:creator>Dusan Malusev</dc:creator>
      <pubDate>Sat, 23 May 2026 12:55:57 +0000</pubDate>
      <link>https://dev.to/malusev998/self-registering-class-descriptors-deleting-minit-boilerplate-in-a-php-extension-4jc4</link>
      <guid>https://dev.to/malusev998/self-registering-class-descriptors-deleting-minit-boilerplate-in-a-php-extension-4jc4</guid>
      <description>&lt;p&gt;The ScyllaDB PHP driver used to open MINIT with ~70 calls to hand-written &lt;code&gt;define_*()&lt;/code&gt; functions, one per registered class. The order mattered — parent before child, interface before implementer, &lt;code&gt;Value&lt;/code&gt; before &lt;code&gt;Bigint&lt;/code&gt; — and the only way to know you'd got it wrong was a segfault during &lt;code&gt;php -m&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That whole block is now one call. Each class declares itself at file scope, a &lt;code&gt;__attribute__((constructor))&lt;/code&gt; appends it to a linked list at &lt;code&gt;.so&lt;/code&gt; load time, and &lt;code&gt;php_scylladb_class_registry_minit()&lt;/code&gt; topologically sorts by FQN and registers in dependency order. Missing or cyclic deps fail loudly at MINIT with the class name in the message.&lt;/p&gt;

&lt;p&gt;This post is the second in the &lt;a href="https://dev.to/blog/scylladb-php-driver-the-story-so-far"&gt;ScyllaDB PHP driver&lt;/a&gt; series. The pattern isn't novel — kernel module init tables and &lt;code&gt;static_init&lt;/code&gt;-style registries have done this in C for decades — but it solves a problem PHP extension authors hit on day one and most of us paper over with a hand-maintained switch statement. If you've ever stared at a 50-line MINIT and wished the file declared its own registration, this is what that looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  the problem MINIT actually has
&lt;/h2&gt;

&lt;p&gt;A PHP extension declares its classes in &lt;code&gt;PHP_MINIT_FUNCTION(ext)&lt;/code&gt;. The mechanical part is &lt;code&gt;INIT_CLASS_ENTRY&lt;/code&gt; + &lt;code&gt;zend_register_internal_class&lt;/code&gt; (or, since PHP 8.3, the &lt;code&gt;register_class_*()&lt;/code&gt; helpers generated from &lt;code&gt;.stub.php&lt;/code&gt;). The political part is that you have to call them in the right order.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;zend_register_internal_class_ex&lt;/code&gt; wants a parent &lt;code&gt;zend_class_entry*&lt;/code&gt;. If the parent hasn't been registered yet, you pass &lt;code&gt;NULL&lt;/code&gt; and the child silently extends nothing. Interface implementers want the interface &lt;code&gt;ce*&lt;/code&gt; to be present before they call &lt;code&gt;zend_class_implements&lt;/code&gt;. So MINIT becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// MINIT — the version this PR replaced&lt;/span&gt;
&lt;span class="n"&gt;define_Cassandra_Value&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// interface, no deps&lt;/span&gt;
&lt;span class="n"&gt;define_Cassandra_Numeric&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;        &lt;span class="c1"&gt;// interface, no deps&lt;/span&gt;
&lt;span class="n"&gt;define_Cassandra_RetryPolicy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// interface, no deps&lt;/span&gt;
&lt;span class="n"&gt;define_Cassandra_Bigint&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;         &lt;span class="c1"&gt;// implements Value, Numeric&lt;/span&gt;
&lt;span class="n"&gt;define_Cassandra_RetryPolicy_DefaultPolicy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// implements RetryPolicy&lt;/span&gt;
&lt;span class="c1"&gt;// … 65 more lines, all order-sensitive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no checker. The compiler can't tell you that &lt;code&gt;Bigint&lt;/code&gt; came before &lt;code&gt;Value&lt;/code&gt;; it's just two function calls in a &lt;code&gt;.c&lt;/code&gt; file. Renaming a class, adding an interface, or reordering for readability all have the same failure mode: a class entry with the wrong parent, discovered the first time someone instantiates it.&lt;/p&gt;

&lt;p&gt;The other problem is locality. The class lives in &lt;code&gt;src/Bigint.c&lt;/code&gt;. Its interfaces live in &lt;code&gt;src/Numeric.stub.php&lt;/code&gt; and &lt;code&gt;src/Value.stub.php&lt;/code&gt;. But the &lt;em&gt;registration order&lt;/em&gt; lives in &lt;code&gt;src/php_driver.c&lt;/code&gt; — a file most contributors never edit. Adding a class meant editing three or four places, in two languages, and remembering an ordering rule that wasn't enforced anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  the registry
&lt;/h2&gt;

&lt;p&gt;Every class file declares a descriptor at file scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/RetryPolicy/DefaultPolicy.c (excerpt)&lt;/span&gt;
&lt;span class="n"&gt;PHP_SCYLLADB_REGISTER_CLASS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;retry_policy_default_policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;// C identifier suffix&lt;/span&gt;
    &lt;span class="s"&gt;"Cassandra&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;RetryPolicy&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;DefaultPolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// FQN&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;php_scylladb_retry_policy_default_policy_ce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// where to publish the ce*&lt;/span&gt;
    &lt;span class="s"&gt;"Cassandra&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;RetryPolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// parent FQN (or nullptr)&lt;/span&gt;
    &lt;span class="n"&gt;php_scylladb_retry_policy_default_policy_register&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The macro expands to a &lt;code&gt;static&lt;/code&gt; descriptor struct + a constructor function that appends it to a global linked list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Registry/Registry.h&lt;/span&gt;
&lt;span class="cp"&gt;#define PHP_SCYLLADB_REGISTER_CLASS(slug, _name, _ce_out, _parent, _register_fn) \
    static const char *const scylladb_cls_##slug##_deps[] = { (_parent), nullptr }; \
    static php_scylladb_class_descriptor_t scylladb_cls_##slug = {              \
        .name       = (_name),                                                  \
        .deps       = ((_parent) == nullptr ? nullptr : scylladb_cls_##slug##_deps), \
        .ce_out     = (_ce_out),                                                \
        .register_  = (_register_fn),                                           \
        .next       = nullptr,                                                  \
        .registered = false,                                                    \
    };                                                                          \
    __attribute__((constructor))                                                \
    static void scylladb_cls_register_##slug##_ctor(void) {                     \
        php_scylladb_class_registry_add(&amp;amp;scylladb_cls_##slug);                  \
    }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;__attribute__((constructor))&lt;/code&gt; is a GCC/Clang extension — also accepted by recent MSVC via &lt;code&gt;/Zc:__attribute__&lt;/code&gt; — that runs the function during the dynamic linker's &lt;code&gt;_init&lt;/code&gt; phase, before &lt;code&gt;dlopen&lt;/code&gt; returns to PHP and before MINIT fires. By the time &lt;code&gt;php_scylladb_class_registry_minit()&lt;/code&gt; runs, every descriptor in every &lt;code&gt;.o&lt;/code&gt; linked into the extension has already added itself to &lt;code&gt;registry_head&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's one linker subtlety to flag now, because it'll bite you later: if a &lt;code&gt;.o&lt;/code&gt; file has no externally referenced symbols, the static linker drops it from the final binary and the constructor never runs. The CMake wiring deals with this by linking the descriptor archives with &lt;code&gt;$&amp;lt;LINK_LIBRARY:WHOLE_ARCHIVE,…&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cmake"&gt;&lt;code&gt;&lt;span class="c1"&gt;# cmake/GenStubs.cmake — module libs are linked whole, no GC&lt;/span&gt;
&lt;span class="nb"&gt;target_link_libraries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;php_scylladb_ext PRIVATE
    &lt;span class="s2"&gt;"$&amp;lt;LINK_LIBRARY:WHOLE_ARCHIVE,php_scylladb_retry_policy&amp;gt;"&lt;/span&gt;
    &lt;span class="s2"&gt;"$&amp;lt;LINK_LIBRARY:WHOLE_ARCHIVE,php_scylladb_value&amp;gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# …&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that, the linker sees &lt;code&gt;DefaultPolicy_descriptor.o&lt;/code&gt;, notices nothing else references it, drops it, and the constructor — which exists purely for its side effect — never runs. The class then doesn't exist at MINIT. It's the constructor-attribute equivalent of forgetting &lt;code&gt;extern&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  topo-sort at MINIT
&lt;/h2&gt;

&lt;p&gt;The runtime side is small enough to paste in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/Registry/Registry.c — topo sort, Kahn-ish&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;php_scylladb_class_registry_minit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&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="n"&gt;php_scylladb_class_descriptor_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;registry_head&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;next&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="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;registered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="n"&gt;zend_class_entry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;MAX_DEPS_PER_CLASS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;nullptr&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;n_deps&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="n"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;defer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&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="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;nullptr&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="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&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="n"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;dep_deferred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="n"&gt;zend_class_entry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolve_dep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;dep_deferred&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="n"&gt;dep_deferred&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;defer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&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="n"&gt;ce&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;zend_error_noreturn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;E_CORE_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="s"&gt;"scylladb registry: class '%s' declares dep '%s' which is neither registered nor known to Zend"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ce&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="n"&gt;n_deps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&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="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="n"&gt;defer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="n"&gt;zend_class_entry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;register_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_deps&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ce_out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ce&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;registered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;progress&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&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="cm"&gt;/* Anything still un-registered means a cyclic or missing dep. */&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;php_scylladb_class_descriptor_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;registry_head&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;next&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;registered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;zend_error_noreturn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;E_CORE_ERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"scylladb registry: class '%s' could not be registered (cyclic or missing registry-owned dep)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's O(n²) — for each pass over the list, every descriptor whose deps are now resolved gets registered, and the loop reruns until a full pass makes no progress. The driver has ~120 classes, MINIT runs once per process, and the constant factor is &lt;code&gt;strcmp&lt;/code&gt; on FQN strings. The full registration pass is below 1 ms on a 2023 M2.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;resolve_dep&lt;/code&gt; does the only interesting thing in the file: it tries the registry first (so registry-owned classes resolve cleanly), and falls back to &lt;code&gt;zend_lookup_class&lt;/code&gt; for anything it doesn't own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;zend_class_entry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;resolve_dep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;fqn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;deferred&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;deferred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;php_scylladb_class_descriptor_t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;registry_find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fqn&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="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;nullptr&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;registered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;deferred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;ce_out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;zend_string&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;lookup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zend_string_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fqn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fqn&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="n"&gt;zend_class_entry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zend_lookup_class&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;zend_string_release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ce&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 second branch is what makes SPL parents work. &lt;code&gt;\Cassandra\Exception\DivideByZeroException&lt;/code&gt; ultimately inherits from &lt;code&gt;\RangeException&lt;/code&gt;, which the driver doesn't own and can't &lt;code&gt;INIT_CLASS_ENTRY&lt;/code&gt; on. &lt;code&gt;zend_lookup_class&lt;/code&gt; finds it in Zend's global class table, the registry treats it as already-resolved, and the child registers normally.&lt;/p&gt;

&lt;p&gt;The error messages name the culprit. If you add a class with a dep on &lt;code&gt;"Cassandra\\Foo"&lt;/code&gt; and nothing declares &lt;code&gt;Foo&lt;/code&gt;, MINIT aborts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP Fatal error: scylladb registry: class 'Cassandra\Bar' declares dep
'Cassandra\Foo' which is neither registered nor known to Zend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the bit I care about most. The old MINIT failed silently — a class would register with &lt;code&gt;parent_ce = NULL&lt;/code&gt; and the bug would surface during &lt;code&gt;instanceof&lt;/code&gt; checks. The registry fails at module load, before a single request runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  the part you don't write
&lt;/h2&gt;

&lt;p&gt;Two generators, one stub. The split between them is the whole point.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gen_stub.php&lt;/code&gt; ships with &lt;a href="https://github.com/php/php-src/blob/master/build/gen_stub.php" rel="noopener noreferrer"&gt;php-src&lt;/a&gt; and every modern extension uses it. Feed it &lt;code&gt;DefaultPolicy.stub.php&lt;/code&gt; and it emits &lt;code&gt;DefaultPolicy_arginfo.h&lt;/code&gt; containing &lt;code&gt;register_class_Cassandra_RetryPolicy_DefaultPolicy()&lt;/code&gt;. That function is where the &lt;code&gt;zend_class_entry&lt;/code&gt; is actually born — &lt;code&gt;INIT_CLASS_ENTRY&lt;/code&gt;, class flags, property declarations, &lt;code&gt;zend_register_internal_class_ex&lt;/code&gt;. Mature, version-aware, not mine to touch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tools/gen_descriptor/gen_class_descriptor.php&lt;/code&gt; is mine. 513 lines of straight PHP, no dependencies. Same stub, different output: &lt;code&gt;DefaultPolicy_descriptor.c&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tools/gen_descriptor/gen_class_descriptor.php&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * What it produces:
 *   - the `php_scylladb_&amp;lt;snake&amp;gt;_ce` global — the pointer the rest of the
 *     extension reads; the class entry itself is built by register_class_*()
 *     from _arginfo.h
 *   - the `zend_object_handlers php_scylladb_&amp;lt;snake&amp;gt;_handlers` global
 *   - a register fn that calls register_class_*() with deps wired from
 *     the stub's extends/implements, then applies create_object + handler
 *     overrides via weakly-declared callbacks, and calls a weak
 *     post_register hook
 *   - the PHP_SCYLLADB_REGISTER_CLASS / _DEPS macro invocation
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the split this way: php-src builds the class entry, my generator hangs it on the registry. Both run on every build, both write into the build directory, neither file lives in git. CMake exposes them as siblings — &lt;code&gt;php_scylladb_generate_arginfo()&lt;/code&gt; and &lt;code&gt;php_scylladb_generate_descriptor()&lt;/code&gt; — and you point both at the same &lt;code&gt;.stub.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That leaves four things per class for a human to write. ZEND_METHOD bodies. The convention-named callbacks — &lt;code&gt;_new&lt;/code&gt;, &lt;code&gt;_free&lt;/code&gt;, &lt;code&gt;_compare&lt;/code&gt;, &lt;code&gt;_gc&lt;/code&gt;, &lt;code&gt;_clone&lt;/code&gt;, &lt;code&gt;_cast&lt;/code&gt;, &lt;code&gt;_hash_value&lt;/code&gt;, &lt;code&gt;_post_register&lt;/code&gt; — declared as weak symbols so the ones you skip stay NULL. Any public C helpers like &lt;code&gt;_instantiate&lt;/code&gt;. And the stub itself. Everything in between is build output.&lt;/p&gt;

&lt;p&gt;There are two annotations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@scylladb-value-handlers&lt;/code&gt; in a class docblock opts into the &lt;code&gt;php_scylladb_value_handlers&lt;/code&gt; struct (standard handlers + an extra &lt;code&gt;hash_value&lt;/code&gt; slot). Applied to &lt;code&gt;Bigint&lt;/code&gt;, &lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Blob&lt;/code&gt;, &lt;code&gt;Inet&lt;/code&gt;, and the rest of the Value-typed classes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@scylladb-no-generate&lt;/code&gt; is the escape hatch for classes that need a fully hand-written descriptor. Currently used by &lt;code&gt;Custom&lt;/code&gt; (no stub at all — legacy &lt;code&gt;INIT_CLASS_ENTRY&lt;/code&gt; path) and the SPL-rooted exception batch in &lt;code&gt;src/Exception/exceptions.c&lt;/code&gt;, which registers 23 classes in one call and doesn't want the per-class machinery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding a class now looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write &lt;code&gt;Foo.stub.php&lt;/code&gt; with &lt;code&gt;extends&lt;/code&gt;/&lt;code&gt;implements&lt;/code&gt; declared in PHP.&lt;/li&gt;
&lt;li&gt;Define &lt;code&gt;php_scylladb_foo_new&lt;/code&gt; (and any other convention-named callbacks) in &lt;code&gt;Foo.c&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;List the stub in the module's &lt;code&gt;CMakeLists.txt&lt;/code&gt; under &lt;code&gt;php_scylladb_generate_arginfo&lt;/code&gt; and &lt;code&gt;php_scylladb_generate_descriptor&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No edits to &lt;code&gt;php_scylladb.c&lt;/code&gt;. No edits to &lt;code&gt;include/php_scylladb_types.h&lt;/code&gt;. No order to remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it isn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not portable to MSVC's default mode.&lt;/strong&gt; &lt;code&gt;__attribute__((constructor))&lt;/code&gt; is a GCC/Clang feature. MSVC needs &lt;code&gt;/Zc:__attribute__&lt;/code&gt; or the &lt;code&gt;.CRT$XCU&lt;/code&gt; section trick. The driver doesn't target Windows yet, so this hasn't bitten us.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not save you from cycles in the dep graph.&lt;/strong&gt; The topo-sort detects them at MINIT and aborts; it does not break them. A circular &lt;code&gt;A → B → A&lt;/code&gt; is your bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a substitute for ZEND_BEGIN_MODULE_GLOBALS or the rest of MINIT.&lt;/strong&gt; Functions, INI entries, constants, persistent resources — all still register the old way. The registry only owns class entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole-archive link is non-negotiable.&lt;/strong&gt; If you skip the &lt;code&gt;LINK_LIBRARY:WHOLE_ARCHIVE&lt;/code&gt; guard for any module, you get a silently-missing class, which is the exact failure mode the registry was designed to eliminate. Audit it on every new module.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated descriptors mean stub correctness matters.&lt;/strong&gt; A typo in &lt;code&gt;extends \Cassandra\Foo&lt;/code&gt; in a &lt;code&gt;.stub.php&lt;/code&gt; now becomes a MINIT-time failure with a clear message, not a compile error. That's a feature for me; if you prefer compile-time failure, this is a regression.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  where to start
&lt;/h2&gt;

&lt;p&gt;The full implementation is two files, ~170 lines of C: &lt;a href="https://github.com/he4rt/scylladb-php-driver/blob/master/src/Registry/Registry.h" rel="noopener noreferrer"&gt;Registry.h&lt;/a&gt; and &lt;a href="https://github.com/he4rt/scylladb-php-driver/blob/master/src/Registry/Registry.c" rel="noopener noreferrer"&gt;Registry.c&lt;/a&gt;. The generator is one PHP file at &lt;a href="https://github.com/he4rt/scylladb-php-driver/blob/master/tools/gen_descriptor/gen_class_descriptor.php" rel="noopener noreferrer"&gt;tools/gen_descriptor/gen_class_descriptor.php&lt;/a&gt;. All three are MIT-licensed; lift them into your own extension if the pattern fits.&lt;/p&gt;

&lt;p&gt;I wrote the registry the week I deleted ZendCPP. Once the per-class descriptor was a generated file, the C++ that backed it stopped having a job.&lt;/p&gt;

</description>
      <category>php</category>
      <category>c23</category>
      <category>phpextensions</category>
      <category>scylladb</category>
    </item>
    <item>
      <title>dlopen, dlsym, and how PHP loads extensions</title>
      <dc:creator>Dusan Malusev</dc:creator>
      <pubDate>Tue, 19 May 2026 17:02:24 +0000</pubDate>
      <link>https://dev.to/malusev998/dlopen-dlsym-and-how-php-loads-extensions-50i7</link>
      <guid>https://dev.to/malusev998/dlopen-dlsym-and-how-php-loads-extensions-50i7</guid>
      <description>&lt;p&gt;Most programs are linked at build time. &lt;code&gt;dlopen&lt;/code&gt; is for everything else: plugin systems, audio engines that load VST modules, language runtimes that pull in extensions, game loops that reload logic without restarting — and GPU drivers. The mechanism is identical across all of them. Four POSIX functions, one opaque handle, and a contract the compiler cannot enforce for you.&lt;/p&gt;

&lt;p&gt;This is that contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  API vs ABI
&lt;/h2&gt;

&lt;p&gt;These two acronyms get conflated constantly. They describe different things, enforced at different times, by different tools.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;API&lt;/strong&gt; (Application Programming Interface) is a source-level contract. It defines what you can call, with what argument types, and what you get back. The compiler enforces it. Pass an &lt;code&gt;int&lt;/code&gt; where a &lt;code&gt;const char *&lt;/code&gt; is expected and you get a type error before a binary exists. Change a function signature in a header and every file including that header fails to compile. The feedback is immediate and impossible to miss.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;ABI&lt;/strong&gt; (Application Binary Interface) is a binary-level contract. It defines how compiled code actually executes a call: which registers carry which arguments, how structs are laid out in memory, and what a function's name looks like in the symbol table after the compiler has processed it. Nothing enforces this automatically. Two translation units can agree at the source level — identical header, identical types, identical function names — and still disagree at the binary level if compiled with different compilers, different compiler versions, different flags, or different target ABIs.&lt;/p&gt;

&lt;p&gt;The gap between them is where &lt;code&gt;dlopen&lt;/code&gt; lives.&lt;/p&gt;

&lt;p&gt;When you link two &lt;code&gt;.o&lt;/code&gt; files together, the linker sees both sides simultaneously. Any ABI inconsistency either resolves cleanly or produces a linker error. When you &lt;code&gt;dlopen&lt;/code&gt; a &lt;code&gt;.so&lt;/code&gt; at runtime, the two sides were compiled separately — possibly years apart, by different people, with different toolchains. The API contract (the header) might be unchanged. The ABI might not be.&lt;/p&gt;

&lt;p&gt;A concrete example: a library ships &lt;code&gt;struct Config { int version; char *name; }&lt;/code&gt;. Everything compiles. Two years later, a field is inserted between &lt;code&gt;version&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt;. The function signatures are untouched; the API is compatible if you recompile. But a plugin compiled against the old header has &lt;code&gt;name&lt;/code&gt; at byte offset 4. The new library reads &lt;code&gt;name&lt;/code&gt; from offset 8. No compiler error. No linker error. A pointer read from the wrong address, producing garbage or a crash.&lt;/p&gt;

&lt;p&gt;This is why "we didn't change the API" is not sufficient. The relevant question is whether the ABI changed.&lt;/p&gt;

&lt;p&gt;ABI stability is a harder property than API stability. It requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;never reordering struct fields&lt;/li&gt;
&lt;li&gt;never inserting fields between existing ones&lt;/li&gt;
&lt;li&gt;never changing the size of any exported type&lt;/li&gt;
&lt;li&gt;never changing a function signature in ways that affect register assignment&lt;/li&gt;
&lt;li&gt;using the same name mangling scheme — which implies the same language and often the same compiler family&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Linux distributions maintain ABI stability for core system libraries across release cycles. Most application libraries do not, and signal breaks via &lt;code&gt;SONAME&lt;/code&gt; version bumps — &lt;code&gt;libfoo.so.1&lt;/code&gt; → &lt;code&gt;libfoo.so.2&lt;/code&gt; — so the dynamic linker refuses to load the wrong version. &lt;code&gt;dlopen&lt;/code&gt; by bare filename bypasses even that check. You own the contract entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  why dynamic loading exists
&lt;/h2&gt;

&lt;p&gt;A statically linked binary resolves every symbol at link time. Addresses are baked in before the binary touches disk — predictable, fast, and completely inflexible. &lt;code&gt;dlopen&lt;/code&gt; goes further than the normal dynamic linker: not only are the libraries separate from the host binary, the decision of &lt;em&gt;which&lt;/em&gt; library to load and &lt;em&gt;when&lt;/em&gt; happens at runtime, in your code.&lt;/p&gt;

&lt;p&gt;The cost is real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No compile-time type checking across the boundary&lt;/li&gt;
&lt;li&gt;No ABI guarantees by default&lt;/li&gt;
&lt;li&gt;The linker cannot dead-strip code it never sees&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you get in exchange depends on what problem you're solving:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin systems&lt;/strong&gt; — discover and load behavior the host binary never knew about at build time. A text editor loading syntax highlighters, a game engine loading user mods, an audio DAW loading instrument plugins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hot reloading&lt;/strong&gt; — recompile a &lt;code&gt;.so&lt;/code&gt; while the host process is running, swap the old handle for the new one, and continue with updated logic without restarting. The key design constraint is that &lt;em&gt;state&lt;/em&gt; lives in the host and &lt;em&gt;behavior&lt;/em&gt; lives in the &lt;code&gt;.so&lt;/code&gt;. The host allocates the game world, the simulation state, the in-memory database — whatever must survive across reloads. The &lt;code&gt;.so&lt;/code&gt; contains only the code that operates on it. When the &lt;code&gt;.so&lt;/code&gt; is recompiled, the host calls &lt;code&gt;dlclose&lt;/code&gt; on the old handle and &lt;code&gt;dlopen&lt;/code&gt; on the new one between two iterations of its main loop, re-resolves the function pointers via &lt;code&gt;dlsym&lt;/code&gt;, and continues. The state was never in the &lt;code&gt;.so&lt;/code&gt;, so nothing is lost. This is a development workflow rather than a production deployment strategy, but it eliminates the restart-and-reproduce cycle for anything with a slow startup — a game engine loading assets, a simulation with expensive initialization, a server with a warm cache. Tsoding has demonstrated this pattern repeatedly on stream: game logic in a &lt;code&gt;.so&lt;/code&gt;, main loop polling for mtime changes, swap between frames, state intact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Language runtimes&lt;/strong&gt; — PHP, Python, Ruby, and Lua all use &lt;code&gt;dlopen&lt;/code&gt; to pull in native extensions. The interpreter is the host; the extension is the plugin; &lt;code&gt;dlsym&lt;/code&gt; finds the entry point by a known name convention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPU and audio drivers&lt;/strong&gt; — the OS loads the right driver for the installed hardware without recompiling anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  the four functions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// &amp;lt;dlfcn.h&amp;gt;&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;dlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;dlsym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;dlerror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt;   &lt;span class="nf"&gt;dlclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;handle&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;dlopen&lt;/code&gt; maps the library into the process's address space and returns an opaque handle. Two flag pairs matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RTLD_LAZY&lt;/code&gt; — resolve symbols only when called for the first time. Faster startup; missing symbols fail at call time, mid-execution.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RTLD_NOW&lt;/code&gt; — resolve everything immediately. A missing symbol aborts at &lt;code&gt;dlopen&lt;/code&gt; time, not later.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RTLD_LOCAL&lt;/code&gt; (default) — keeps the library's symbols private to this handle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RTLD_GLOBAL&lt;/code&gt; — dumps all exported symbols into the process-wide namespace. Every library loaded afterward can see them. PHP uses this; the cost is that two extensions with the same symbol name silently shadow one another based on load order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;dlsym&lt;/code&gt; does a hash lookup for a named symbol and returns its address as &lt;code&gt;void *&lt;/code&gt;. The correct error-checking pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;dlerror&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                           &lt;span class="c1"&gt;// clear any stale error&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sym&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dlsym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dlerror&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="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* sym is unusable, do not call it */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking &lt;code&gt;sym != NULL&lt;/code&gt; is not enough — a valid symbol can theoretically reside at address zero, and a failed lookup can return &lt;code&gt;NULL&lt;/code&gt; without setting an error in some edge cases. The &lt;code&gt;dlerror&lt;/code&gt; round-trip is the only reliable path.&lt;/p&gt;

&lt;h2&gt;
  
  
  minimal C23 example
&lt;/h2&gt;

&lt;p&gt;Two translation units: the plugin compiled to &lt;code&gt;.so&lt;/code&gt;, and the host that loads it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// plugin.c — cc -std=c23 -fPIC -fvisibility=hidden -shared -o plugin.so plugin.c&lt;/span&gt;

&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="n"&gt;__attribute__&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hello, %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&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;-fPIC&lt;/code&gt; (position-independent code) is required for shared libraries. Addresses inside the &lt;code&gt;.so&lt;/code&gt; are relative offsets rather than absolute, so the same file maps at different virtual addresses in different processes. &lt;code&gt;-fvisibility=hidden&lt;/code&gt; makes hidden the default; &lt;code&gt;visibility("default")&lt;/code&gt; opts individual symbols back in. Without it, your entire symbol table is exported — a maintenance hazard and a collision risk in large plugin ecosystems.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// host.c — cc -std=c23 -o host host.c -ldl&lt;/span&gt;

&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;dlfcn.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdint.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdlib.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;greet_fn&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;auto&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"./plugin.so"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RTLD_NOW&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;RTLD_LOCAL&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"dlopen: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dlerror&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;EXIT_FAILURE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;dlerror&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;greet_fn&lt;/span&gt; &lt;span class="n"&gt;greet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;greet_fn&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="kt"&gt;uintptr_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;dlsym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"greet"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dlerror&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="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"dlsym: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;dlclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;EXIT_FAILURE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"world"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;dlclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;EXIT_SUCCESS&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;auto handle&lt;/code&gt; is valid C23 — the type is inferred as &lt;code&gt;void *&lt;/code&gt; from &lt;code&gt;dlopen&lt;/code&gt;'s return type. Same semantics as C++ &lt;code&gt;auto&lt;/code&gt;, standardized in C 13 years later.&lt;/p&gt;

&lt;p&gt;The cast &lt;code&gt;(greet_fn)(uintptr_t)dlsym(...)&lt;/code&gt; is the standard-conforming path from &lt;code&gt;void *&lt;/code&gt; to a function pointer. ISO C does not guarantee &lt;code&gt;void *&lt;/code&gt; and function pointers share the same representation. POSIX guarantees it specifically for &lt;code&gt;dlsym&lt;/code&gt;, but the double-cast suppresses the pedantic diagnostic cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  how PHP loads extensions
&lt;/h2&gt;

&lt;p&gt;PHP is one specific application of this exact pattern. Its loader lives in &lt;code&gt;main/dl.c&lt;/code&gt;. The core of &lt;code&gt;php_load_extension()&lt;/code&gt; — simplified but structurally faithful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main/dl.c (php-src)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DL_LOAD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;libpath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// DL_LOAD is dlopen() on POSIX&lt;/span&gt;

&lt;span class="n"&gt;zend_module_entry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;get_module&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dlsym&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"get_module"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;zend_module_entry&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;module_entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_module&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;zend_register_module_ex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module_entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;zend_startup_module_ex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;module_entry&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;get_module&lt;/code&gt; is PHP's &lt;code&gt;update&lt;/code&gt; — a known-name entry point the host finds via &lt;code&gt;dlsym&lt;/code&gt;. The difference is the payload: instead of a function pointer to game logic, it returns a pointer to &lt;code&gt;zend_module_entry&lt;/code&gt;, PHP's struct describing everything the extension provides.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;zend_module_entry&lt;/span&gt; &lt;span class="n"&gt;myext_module_entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;STANDARD_MODULE_HEADER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"myext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;myext_functions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="cm"&gt;/* NULL-terminated Zend function table */&lt;/span&gt;
    &lt;span class="n"&gt;PHP_MINIT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;myext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="cm"&gt;/* called once at process startup */&lt;/span&gt;
    &lt;span class="n"&gt;PHP_MSHUTDOWN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;myext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;PHP_RINIT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;myext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="cm"&gt;/* called per request */&lt;/span&gt;
    &lt;span class="n"&gt;PHP_RSHUTDOWN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;myext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;PHP_MINFO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;myext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;STANDARD_MODULE_PROPERTIES&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="cm"&gt;/* expands to: zend_module_entry *get_module(void) { return &amp;amp;myext_module_entry; } */&lt;/span&gt;
&lt;span class="n"&gt;ZEND_GET_MODULE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;myext&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;RINIT&lt;/code&gt;/&lt;code&gt;RSHUTDOWN&lt;/code&gt; run once per request; &lt;code&gt;MINIT&lt;/code&gt;/&lt;code&gt;MSHUTDOWN&lt;/code&gt; once per worker process lifetime. PHP passes &lt;code&gt;RTLD_GLOBAL | RTLD_LAZY&lt;/code&gt; by default. &lt;code&gt;RTLD_GLOBAL&lt;/code&gt; is intentional — some extensions wrap C++ libraries that need their own symbols visible to later-loaded libraries. The consequence is that two extensions exporting the same symbol name silently shadow each other by load order. No warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rust without headaches
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;libloading&lt;/code&gt; wraps &lt;code&gt;dlopen&lt;/code&gt;/&lt;code&gt;dlsym&lt;/code&gt; with a type-safe API. The unsafe surface shrinks to the unavoidable: loading the library and declaring the expected function signature.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;libloading&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.8"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.rs&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;libloading&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Library&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// SAFETY: plugin.so is a well-formed shared library we control.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nn"&gt;Library&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"./plugin.so"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="c1"&gt;// SAFETY: "greet" exists, takes *const c_char, returns c_int.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Symbol&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;i8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b"greet&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="s"&gt;"world"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="nf"&gt;.as_ptr&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&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;Library::new&lt;/code&gt; calls &lt;code&gt;dlopen&lt;/code&gt; and maps the error to Rust's &lt;code&gt;Error&lt;/code&gt; trait. &lt;code&gt;lib.get&lt;/code&gt; calls &lt;code&gt;dlsym&lt;/code&gt; — &lt;code&gt;b"greet\0"&lt;/code&gt; is the null-terminated symbol name exactly as &lt;code&gt;dlsym&lt;/code&gt; expects. &lt;code&gt;Symbol&amp;lt;F&amp;gt;&lt;/code&gt; carries the function signature, so calling &lt;code&gt;greet(...)&lt;/code&gt; type-checks the arguments at compile time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Symbol&amp;lt;T&amp;gt;&lt;/code&gt; borrows from &lt;code&gt;Library&lt;/code&gt;, so the borrow checker prevents calling a symbol after the library is dropped — a use-after-free class of bug that C gives you silently.&lt;/p&gt;

&lt;p&gt;For anything real, wrap the FFI behind a typed struct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Plugin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_lib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Library&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;Plugin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;libloading&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nn"&gt;Library&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="c1"&gt;// Dereference copies the fn pointer out of Symbol — fn pointers are Copy,&lt;/span&gt;
        &lt;span class="c1"&gt;// so we own the value before Symbol's borrow of lib ends.&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="py"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;b"update&lt;/span&gt;&lt;span class="se"&gt;\0&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_lib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.update&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_lib: Library&lt;/code&gt; keeps the shared library alive for the struct's lifetime. Dropping &lt;code&gt;Plugin&lt;/code&gt; calls &lt;code&gt;dlclose&lt;/code&gt;. Callers never see &lt;code&gt;unsafe&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  the ABI in detail
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;dlsym&lt;/code&gt; returns an address and trusts you've declared the right type to call through. There are three independent ways to get that wrong: symbol name, calling convention, and struct layout. Each fails silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  calling convention
&lt;/h3&gt;

&lt;p&gt;A calling convention is the machine-level agreement between caller and callee: which registers carry arguments, who restores the stack, and where the return value lands.&lt;/p&gt;

&lt;p&gt;The x86-64 System V ABI (Linux, macOS, BSDs) assigns integer and pointer arguments in this order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;position&lt;/th&gt;
&lt;th&gt;register&lt;/th&gt;
&lt;th&gt;32-bit alias&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1st&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rdi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;edi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2nd&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rsi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;esi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3rd&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rdx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;edx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4th&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rcx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ecx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5th&lt;/td&gt;
&lt;td&gt;&lt;code&gt;r8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;r8d&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6th&lt;/td&gt;
&lt;td&gt;&lt;code&gt;r9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;r9d&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7th+&lt;/td&gt;
&lt;td&gt;stack&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Return value comes back in &lt;code&gt;rax&lt;/code&gt;. Float arguments use &lt;code&gt;xmm0&lt;/code&gt;–&lt;code&gt;xmm7&lt;/code&gt;. Registers &lt;code&gt;rax&lt;/code&gt;, &lt;code&gt;rcx&lt;/code&gt;, &lt;code&gt;rdx&lt;/code&gt;, &lt;code&gt;rsi&lt;/code&gt;, &lt;code&gt;rdi&lt;/code&gt;, &lt;code&gt;r8&lt;/code&gt;–&lt;code&gt;r11&lt;/code&gt; are caller-saved — the callee may clobber them. &lt;code&gt;rbx&lt;/code&gt;, &lt;code&gt;rbp&lt;/code&gt;, &lt;code&gt;r12&lt;/code&gt;–&lt;code&gt;r15&lt;/code&gt; are callee-saved — the callee must restore them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// update(&amp;amp;state, 0.016f) on x86-64 System V:&lt;/span&gt;
&lt;span class="c1"&gt;// rdi = &amp;amp;state   ← pointer to State struct&lt;/span&gt;
&lt;span class="c1"&gt;// xmm0 = 0.016f  ← first float argument&lt;/span&gt;
&lt;span class="c1"&gt;// call &amp;lt;address from dlsym&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows x64 differs: &lt;code&gt;rcx&lt;/code&gt;, &lt;code&gt;rdx&lt;/code&gt;, &lt;code&gt;r8&lt;/code&gt;, &lt;code&gt;r9&lt;/code&gt; for the first four, then stack, with 32 bytes of shadow space reserved regardless. A function compiled for one convention, called through the other, reads arguments from wrong registers. No error at any stage — just wrong values.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;extern "C"&lt;/code&gt; in C++ and Rust selects the platform C calling convention. Without it the compiler chooses whatever it wants, and there is no guarantee two compilers choose the same thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  struct layout
&lt;/h3&gt;

&lt;p&gt;C lays out fields in declaration order, each aligned to its own natural size: &lt;code&gt;char&lt;/code&gt; to 1, &lt;code&gt;int&lt;/code&gt; to 4, &lt;code&gt;long&lt;/code&gt; and pointers to 8 on LP64. The struct is padded at the end to a multiple of its largest member's alignment. Gaps are inserted between fields to satisfy alignment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Example&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// offset 0,  size 1&lt;/span&gt;
              &lt;span class="c1"&gt;// ← 3 bytes padding&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt;  &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// offset 4,  size 4&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// offset 8,  size 1&lt;/span&gt;
              &lt;span class="c1"&gt;// ← 7 bytes padding&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// offset 16, size 8&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// sizeof == 24, not 14&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;field&lt;/th&gt;
&lt;th&gt;offset&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;th&gt;padding after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reordering to &lt;code&gt;{ char a; char c; int b; long d; }&lt;/code&gt; produces a 16-byte struct. Same fields, same types, different size. A plugin compiled against the 24-byte layout that reads &lt;code&gt;d&lt;/code&gt; at offset 16 reads from the middle of &lt;code&gt;b&lt;/code&gt; and &lt;code&gt;c&lt;/code&gt; in the 16-byte layout. No error at any point in the toolchain.&lt;/p&gt;

&lt;p&gt;The standard mitigation: put a &lt;code&gt;version&lt;/code&gt; or &lt;code&gt;size&lt;/code&gt; field first in any struct that crosses the plugin boundary, and check it at load time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;PluginAPI&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// must be first, must never move&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// At load time:&lt;/span&gt;
&lt;span class="n"&gt;PluginAPI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_api&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="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;PLUGIN_API_VERSION&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* reject */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  name mangling
&lt;/h3&gt;

&lt;p&gt;C symbol names are function names, verbatim. &lt;code&gt;dlsym(handle, "greet")&lt;/code&gt; finds &lt;code&gt;greet&lt;/code&gt;. C++ encodes namespace, class, and parameter types into the symbol to support overloading. Rust does the same for generics.&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;# C: int greet(const char *name)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;nm &lt;span class="nt"&gt;-D&lt;/span&gt; plugin_c.so | &lt;span class="nb"&gt;grep &lt;/span&gt;greet
00000000000010f0 T greet

&lt;span class="c"&gt;# C++: int greet(const char *name)&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;nm &lt;span class="nt"&gt;-D&lt;/span&gt; plugin_cpp.so | &lt;span class="nb"&gt;grep &lt;/span&gt;greet
00000000000010f0 T _Z5greetPKc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_Z5greetPKc&lt;/code&gt; means: function named &lt;code&gt;greet&lt;/code&gt; (5 chars), taking &lt;code&gt;PKc&lt;/code&gt; (pointer-to-const-char). Decode it with &lt;code&gt;c++filt _Z5greetPKc&lt;/code&gt;. The scheme is not standardized — it differs between GCC and Clang, between versions, between platforms. &lt;code&gt;dlsym(handle, "_Z5greetPKc")&lt;/code&gt; works today and breaks silently after a compiler upgrade.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;extern "C"&lt;/code&gt; suppresses C++ mangling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mangled — unstable symbol name&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;name&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="c1"&gt;// Not mangled — stable, dlsym-able by plain name&lt;/span&gt;
&lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;name&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rust needs both &lt;code&gt;extern "C"&lt;/code&gt; (for calling convention) and &lt;code&gt;#[no_mangle]&lt;/code&gt; (to emit the plain name):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mangled + Rust ABI — dlsym("greet") won't find it&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;i8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// C ABI, plain symbol name — dlsym("greet") finds it&lt;/span&gt;
&lt;span class="nd"&gt;#[no_mangle]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="nb"&gt;i8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  struct layout in Rust
&lt;/h3&gt;

&lt;p&gt;Without &lt;code&gt;#[repr(C)]&lt;/code&gt;, Rust may reorder struct fields to minimize padding. The layout is unspecified and can change between compiler versions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// repr(Rust) — layout unspecified, compiler may reorder&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Foo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Possible layout: b(0), a(4), c(5), pad(6-7) → 8 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// repr(C) — C rules, declaration order preserved&lt;/span&gt;
&lt;span class="nd"&gt;#[repr(C)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Foo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u8&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Guaranteed: a(0), pad(1-3), b(4), c(8), pad(9-11) → 12 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any struct passed by pointer to C, returned from C, or embedded in a C struct must be &lt;code&gt;#[repr(C)]&lt;/code&gt;. Without it, C reads fields from wrong offsets.&lt;/p&gt;

&lt;h2&gt;
  
  
  where it goes wrong
&lt;/h2&gt;

&lt;p&gt;Symbol versioning exists in glibc — &lt;code&gt;.symver&lt;/code&gt; directives, &lt;code&gt;SONAME&lt;/code&gt; in the ELF header — but almost nothing outside of libc and GPU vendors uses it correctly. If your plugin ABI changes, you change the symbol name or the filename. There is no automatic enforcement.&lt;/p&gt;

&lt;p&gt;Crashes inside a loaded plugin take down the whole process. PHP isolates this with shared-nothing worker processes — one per request — not with any sandbox inside the loader. If you need fault isolation, you need process boundaries or a WASM sandbox, not &lt;code&gt;dlopen&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it isn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not a substitute for static linking&lt;/strong&gt; when you control both sides and ship them together. Static linking gives the linker visibility to strip dead code, inline across translation units, and catch missing symbols at build time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not give you ABI stability for free.&lt;/strong&gt; Reordering struct fields, adding a parameter, or switching compiler versions can break a loaded plugin with zero compile-time signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not safe to pass Rust-native types across the boundary.&lt;/strong&gt; &lt;code&gt;Vec&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;Arc&amp;lt;T&amp;gt;&lt;/code&gt; all depend on allocator identity and internal layout that is not stable across compiler versions. Use &lt;code&gt;*const T&lt;/code&gt;/&lt;code&gt;*mut T&lt;/code&gt; with &lt;code&gt;#[repr(C)]&lt;/code&gt; structs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;RTLD_GLOBAL&lt;/code&gt; is not a default you want.&lt;/strong&gt; It pollutes the process-wide symbol namespace. Use &lt;code&gt;RTLD_LOCAL&lt;/code&gt; unless you have a concrete reason, and document it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not work on Windows as written.&lt;/strong&gt; The Windows equivalent is &lt;code&gt;LoadLibrary&lt;/code&gt;/&lt;code&gt;GetProcAddress&lt;/code&gt;. &lt;code&gt;libloading&lt;/code&gt; abstracts over both; the raw POSIX API does not exist on Windows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  where to start
&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;# Plugin&lt;/span&gt;
cc &lt;span class="nt"&gt;-std&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;c23 &lt;span class="nt"&gt;-fPIC&lt;/span&gt; &lt;span class="nt"&gt;-fvisibility&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hidden &lt;span class="nt"&gt;-shared&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; plugin.so plugin.c

&lt;span class="c"&gt;# Host (Linux requires -ldl; macOS includes it in libSystem automatically)&lt;/span&gt;
cc &lt;span class="nt"&gt;-std&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;c23 &lt;span class="nt"&gt;-o&lt;/span&gt; host host.c &lt;span class="nt"&gt;-ldl&lt;/span&gt;

./host
&lt;span class="c"&gt;# hello, world&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the hot-reload loop: compile the above, run the host, then edit &lt;code&gt;logic.c&lt;/code&gt;, run &lt;code&gt;make&lt;/code&gt;, and watch the swap happen without restarting.&lt;/p&gt;

&lt;p&gt;For Rust: &lt;code&gt;cargo add libloading&lt;/code&gt;, use the typed wrapper pattern above, keep &lt;code&gt;unsafe&lt;/code&gt; blocks minimal and commented.&lt;/p&gt;

&lt;p&gt;I built the ScyllaDB PHP driver on this foundation — every &lt;code&gt;ZEND_GET_MODULE&lt;/code&gt; is the structured version of what's described here.&lt;/p&gt;

</description>
      <category>c23</category>
      <category>libdl</category>
      <category>dlopen</category>
      <category>dynamiclibrary</category>
    </item>
    <item>
      <title>laravel-crypto: libsodium, no compromises, and per-user encryption</title>
      <dc:creator>Dusan Malusev</dc:creator>
      <pubDate>Mon, 18 May 2026 21:22:54 +0000</pubDate>
      <link>https://dev.to/malusev998/laravel-crypto-libsodium-no-compromises-and-per-user-encryption-3055</link>
      <guid>https://dev.to/malusev998/laravel-crypto-libsodium-no-compromises-and-per-user-encryption-3055</guid>
      <description>&lt;p&gt;Laravel's default cipher is &lt;code&gt;AES-256-CBC&lt;/code&gt;. That is a 25-year-old design with no built-in authentication — the MAC is bolted on separately by the framework, and the correctness of that construction depends on nobody ever reordering the operations.&lt;/p&gt;

&lt;p&gt;I am not saying it is broken. I am saying PHP has shipped with libsodium since 7.2, and libsodium gives you authenticated encryption by construction. There was no reason to keep using &lt;code&gt;AES-256-CBC&lt;/code&gt; as the default the moment &lt;code&gt;sodium_crypto_aead_xchacha20poly1305_ietf_encrypt&lt;/code&gt; became available in every standard PHP install. I built &lt;a href="https://github.com/MalusevDevelopment/laravel-crypto" rel="noopener noreferrer"&gt;laravel-crypto&lt;/a&gt; because I wanted to stop thinking about that gap every time I started a new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  why libsodium
&lt;/h2&gt;

&lt;p&gt;NaCl — and libsodium as its portable successor — was designed around one principle: remove the ways you can shoot yourself in the foot. Every algorithm choice is made for you. There is no ECB mode. There is no "pick your own MAC." XChaCha20-Poly1305 is authenticated by definition. AEGIS was designed specifically for high-throughput AEAD on hardware with AES acceleration, which includes every modern x86 and ARM chip. The API surface is small enough to understand fully.&lt;/p&gt;

&lt;p&gt;PHP's mcrypt was the old answer. It was unmaintained, it supported ECB, it let you combine incompatible primitives, and it was removed in PHP 7.2. libsodium replaced it — but Laravel did not update its default cipher. &lt;code&gt;AES-256-CBC&lt;/code&gt; stayed in &lt;code&gt;config/app.php&lt;/code&gt;, a leftover from before the ecosystem had anything better. laravel-crypto picks up where mcrypt's removal should have taken things.&lt;/p&gt;

&lt;h2&gt;
  
  
  the drop-in
&lt;/h2&gt;

&lt;p&gt;Swapping the provider is two lines:&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;// bootstrap/providers.php (Laravel 11+)&lt;/span&gt;
&lt;span class="c1"&gt;// Illuminate\Encryption\EncryptionServiceProvider::class,   // remove&lt;/span&gt;
&lt;span class="nc"&gt;CodeLieutenant\LaravelCrypto\ServiceProvider&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// add&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;Crypt::encryptString()&lt;/code&gt; and &lt;code&gt;Crypt::decryptString()&lt;/code&gt; still work exactly as before. All existing code calling the facade keeps working. The only change is what runs underneath.&lt;/p&gt;

&lt;p&gt;Set the cipher in &lt;code&gt;config/app.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'cipher'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Sodium_AEGIS256GCM'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="c1"&gt;// Options: Sodium_AES256GCM, Sodium_XChaCha20Poly1305, Sodium_AEGIS256GCM, Sodium_AEGIS128LGCM, Sodium_SecretBox&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php artisan crypto:keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the full migration for most apps. If you already use &lt;code&gt;AEAD&lt;/code&gt; via &lt;code&gt;APP_PREVIOUS_KEYS&lt;/code&gt;, the library picks those up automatically for decryption during key rotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  the numbers
&lt;/h2&gt;

&lt;p&gt;Benchmarks from PHP 8.5.1 on a MacBook M4 Pro — Apple Silicon has hardware AES-NI and dedicated AEGIS acceleration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Algorithm&lt;/th&gt;
&lt;th&gt;1 KiB enc&lt;/th&gt;
&lt;th&gt;1 KiB dec&lt;/th&gt;
&lt;th&gt;1 MiB enc&lt;/th&gt;
&lt;th&gt;1 MiB dec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel AES-256-CBC&lt;/td&gt;
&lt;td&gt;8.09 μs&lt;/td&gt;
&lt;td&gt;9.98 μs&lt;/td&gt;
&lt;td&gt;5.02 ms&lt;/td&gt;
&lt;td&gt;7.57 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laravel AES-256-GCM&lt;/td&gt;
&lt;td&gt;3.37 μs&lt;/td&gt;
&lt;td&gt;5.33 μs&lt;/td&gt;
&lt;td&gt;1.31 ms&lt;/td&gt;
&lt;td&gt;3.94 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sodium AES-256-GCM&lt;/td&gt;
&lt;td&gt;2.39 μs&lt;/td&gt;
&lt;td&gt;2.58 μs&lt;/td&gt;
&lt;td&gt;1.11 ms&lt;/td&gt;
&lt;td&gt;1.88 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sodium XChaCha20-Poly1305&lt;/td&gt;
&lt;td&gt;3.41 μs&lt;/td&gt;
&lt;td&gt;3.58 μs&lt;/td&gt;
&lt;td&gt;2.21 ms&lt;/td&gt;
&lt;td&gt;2.90 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sodium AEGIS-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.06 μs&lt;/td&gt;
&lt;td&gt;2.27 μs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.82 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.65 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sodium AEGIS-128L&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.03 μs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.14 μs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.90 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.60 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;AEGIS-128L decrypts a 1 MiB payload in 1.60 ms versus Laravel's AES-256-CBC at 7.57 ms. That is 4.7× faster, authenticated, on the same hardware. Sodium AES-256-GCM halves the decryption time compared to Laravel's own GCM implementation — same algorithm, better path through the Sodium extension than through OpenSSL via PHP's stream wrapper.&lt;/p&gt;

&lt;p&gt;XChaCha20-Poly1305 is the conservative choice: well-analyzed, hardware-agnostic, fast on everything. Use it if you need consistent performance on older or constrained hardware without AES acceleration. On ARM or x86 with AES-NI, AEGIS is the right pick.&lt;/p&gt;

&lt;p&gt;The point is not that the difference matters for a single request. It matters when you are decrypting a hundred fields per page load, or processing a batch job over encrypted rows, or streaming large files. &lt;code&gt;AES-256-CBC&lt;/code&gt; at 7.57 ms per MiB is not a performance problem in isolation. As a default that never gets questioned, it adds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  per-user encryption
&lt;/h2&gt;

&lt;p&gt;The standard model — one &lt;code&gt;APP_KEY&lt;/code&gt; encrypts everything — has a structural problem. Anyone with access to that key can decrypt every row in the database. That is the DBA, the sysadmin, the developer with a &lt;code&gt;.env&lt;/code&gt; copy, and any process with access to the environment. If client data confidentiality is a real requirement, "we encrypted the database" is not a complete answer when a single key unlocks all of it.&lt;/p&gt;

&lt;p&gt;Per-user encryption means each user's sensitive fields are encrypted with a key unique to that user. A 32-byte random key is generated at enrollment and wrapped in a self-contained blob stored in the &lt;code&gt;encryption_key&lt;/code&gt; column. Two wrapping modes exist depending on what is available at enrollment time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mode 1 — password-wrapped (0x01, 89 bytes):&lt;/strong&gt; wrapping key derived via Argon2id from the user's plaintext password. &lt;code&gt;APP_KEY&lt;/code&gt; is not involved. Unwrapping requires the password — no server-side secret alone can decrypt these fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mode 2 — server-wrapped (0x02, 73 bytes):&lt;/strong&gt; wrapping key derived via &lt;code&gt;BLAKE2b(appKey, userId)&lt;/code&gt;. Used for auto-enrollment when the plaintext password is unavailable (e.g. OAuth users, Filament). The blob is promoted to Mode 1 transparently the next time the user provides their password.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The unwrapped key is never stored anywhere. It is base64url-encoded, sent to the client as &lt;code&gt;X-Encryption-Token&lt;/code&gt; (header for SPA/API clients) or as an HTTP-only &lt;code&gt;enc_token&lt;/code&gt; cookie (web clients), and re-sent on every subsequent request. The middleware reads it, loads it into the request-scoped context, then zeros it at the end of the response.&lt;/p&gt;

&lt;p&gt;Setting it up:&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;// User model&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CodeLieutenant\LaravelCrypto\Traits\HasUserEncryption&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Authenticatable&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;HasUserEncryption&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;At registration, return the token to the client:&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;$rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;initUserEncryption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$user&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Encryption-Token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encodeEncryptionToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rawKey&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At login, re-derive the token from the password and hand it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$credentials&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Auth&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;user&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;issueEncryptionToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$credentials&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&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;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Encryption-Token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&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;After that, model casts handle the rest transparently:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserSecret&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;casts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'ssn'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;UserEncryptedWithIndex&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;':ssn_index'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading &lt;code&gt;$secret-&amp;gt;ssn&lt;/code&gt; decrypts using the key currently in the request context. Saving writes ciphertext. Nothing else in the application changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  the key in memory
&lt;/h2&gt;

&lt;p&gt;The key lives in a &lt;code&gt;scoped&lt;/code&gt; container — one instance per HTTP request. The &lt;code&gt;BootPerUserEncryption&lt;/code&gt; middleware loads it from the incoming header or cookie, sets it on the context, then clears it in a &lt;code&gt;finally&lt;/code&gt; block:&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;// src/Encryption/UserKey/UserEncryptionContext.php&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;sodium_memzero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__destruct&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clear&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;Setting a variable to &lt;code&gt;null&lt;/code&gt; in PHP does not zero the memory — the garbage collector might hold the reference, and even when it collects, it does not overwrite the bytes. &lt;code&gt;sodium_memzero&lt;/code&gt; does. The key is gone, not just unreferenced.&lt;/p&gt;

&lt;p&gt;For Mode 1 blobs the wrapped blob in the database is useless without the user's password. Compromising &lt;code&gt;APP_KEY&lt;/code&gt; does not help. For Mode 2 blobs, &lt;code&gt;APP_KEY&lt;/code&gt; + &lt;code&gt;userId&lt;/code&gt; is enough to re-derive the wrapping key — that is the acknowledged trade-off for the auto-enrollment path. The moment the user sets a password, &lt;code&gt;issueEncryptionToken()&lt;/code&gt; promotes the blob to Mode 1 automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  blind indexes for searchable fields
&lt;/h2&gt;

&lt;p&gt;Encrypting a column means you can no longer query it. &lt;code&gt;WHERE ssn = ?&lt;/code&gt; stops working when &lt;code&gt;ssn&lt;/code&gt; is ciphertext. The standard solution is a blind index: a deterministic MAC of the plaintext stored alongside the ciphertext, used only for equality lookups.&lt;/p&gt;

&lt;p&gt;The derivation:&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;// src/Encryption/UserKey/BlindIndex.php — compute&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;SensitiveParameter&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$normalise&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="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$userKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$subKey&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;deriveColumnSubKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$normalise&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;mb_strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sodium_crypto_generichash&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="nv"&gt;$subKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;INDEX_BYTES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// src/Encryption/UserKey/BlindIndex.php — column sub-key&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;deriveColumnSubKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$userKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sodium_crypto_generichash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SODIUM_CRYPTO_GENERICHASH_BYTES_MIN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$ctx&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hash&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;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sodium_crypto_kdf_derive_from_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;INDEX_BYTES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;KDF_SUBKEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$userKey&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each column gets a distinct sub-key derived from the user's key via libsodium's official KDF. The index for &lt;code&gt;ssn&lt;/code&gt; is cryptographically separated from the index for &lt;code&gt;email&lt;/code&gt;, even though both come from the same user key. Two users with identical SSNs produce different indexes. Querying:&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="nc"&gt;UserSecret&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ssn_index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserCrypt&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;blindIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'123-45-6789'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ssn'&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;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leakage is explicit: blind indexes leak equality. An attacker with database read access can tell that two rows share a value — they learn nothing about what that value is. Do not put a blind index on a low-cardinality field. &lt;code&gt;gender&lt;/code&gt;, &lt;code&gt;country&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt; flags — no. SSN, passport number, phone, email — yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it isn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It is not a replacement for proper key management. Mode 1 blobs are only as safe as the user's password — weak passwords mean weak wrapping. Mode 2 blobs are only as safe as &lt;code&gt;APP_KEY&lt;/code&gt;. Treat &lt;code&gt;APP_KEY&lt;/code&gt; accordingly: secrets manager, rotation policy, backup.&lt;/li&gt;
&lt;li&gt;It does not protect against runtime compromise. If an attacker can execute arbitrary PHP in your process, they can read the key from the request context during a live request. Encryption protects data at rest, not running code.&lt;/li&gt;
&lt;li&gt;Per-user encryption is not transparent key rotation. Changing a user's password requires &lt;code&gt;rewrapUserEncryption($old, $new)&lt;/code&gt;. Rotating &lt;code&gt;APP_KEY&lt;/code&gt; requires re-wrapping every user's key. Neither is automatic.&lt;/li&gt;
&lt;li&gt;Blind indexes leak equality. Two rows with the same value in the same column for the same user produce the same index. On fields with a small set of possible values, that is dangerous enough to skip the index entirely.&lt;/li&gt;
&lt;li&gt;It is not end-to-end encryption. The raw key is derived server-side and then handed to the client as a token. The server sees it. True E2E requires the key to never reach the server — that is a different architecture and a different threat model entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  where to start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require codelieutenant/laravel-crypto
php artisan vendor:publish &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"CodeLieutenant&lt;/span&gt;&lt;span class="se"&gt;\L&lt;/span&gt;&lt;span class="s2"&gt;aravelCrypto&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;&lt;span class="s2"&gt;erviceProvider"&lt;/span&gt;
php artisan crypto:keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;'cipher' =&amp;gt; 'Sodium_AEGIS256GCM'&lt;/code&gt; in &lt;code&gt;config/app.php&lt;/code&gt;, swap the service provider, run your test suite. For most apps the migration takes twenty minutes.&lt;/p&gt;

&lt;p&gt;Per-user encryption takes longer — you need to decide which columns are sensitive enough to warrant it, wire up the middleware, and handle the password-change re-wrap flow. The docs in &lt;code&gt;docs/UserEncryption.md&lt;/code&gt; cover the full setup. I use it in every project where the question "can your team read this user's data?" has an answer I would be uncomfortable defending.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>encryption</category>
      <category>libsodium</category>
    </item>
    <item>
      <title>ScyllaDB PHP Driver: the story so far</title>
      <dc:creator>Dusan Malusev</dc:creator>
      <pubDate>Mon, 18 May 2026 21:22:28 +0000</pubDate>
      <link>https://dev.to/malusev998/scylladb-php-driver-the-story-so-far-3e9j</link>
      <guid>https://dev.to/malusev998/scylladb-php-driver-the-story-so-far-3e9j</guid>
      <description>&lt;p&gt;The DataStax PHP driver for Cassandra would not compile on PHP 8. That was the whole problem.&lt;/p&gt;

&lt;p&gt;At the time I was working at Nano Interactive. The entire backend was PHP. We were running Apache Cassandra as a primary data store — session data, event pipelines, a few hot lookup tables — and the driver that talked to it was the official DataStax extension. It worked fine on PHP 7. Then PHP 8 came out, we needed to upgrade, and the extension refused to build. Compile errors in the Zend internals layer. &lt;code&gt;make&lt;/code&gt; spitting out pages of incompatible API warnings.&lt;/p&gt;

&lt;p&gt;I went looking for a fix. There was none. DataStax had effectively abandoned the project — no PHP 8 tag, no branch, no response on the open issues. The last meaningful release was years old. The options were: stay on PHP 7 indefinitely, rewrite our Cassandra layer to use something else, or fix the driver ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  why not just switch
&lt;/h2&gt;

&lt;p&gt;Switching looked easy on paper. In practice the codebase had Cassandra-specific types everywhere — &lt;code&gt;Cassandra\Uuid&lt;/code&gt;, &lt;code&gt;Cassandra\Timestamp&lt;/code&gt;, &lt;code&gt;Cassandra\Bigint&lt;/code&gt; — not because of architectural brilliance but because the extension had its own type system and those types had leaked into application code over years. Replacing the driver meant touching hundreds of files. And we had no guarantee that a different client library would give us the same behavioral semantics — retry policies, paging state, consistency levels.&lt;/p&gt;

&lt;p&gt;There was also no other option in the PHP ecosystem. No community-maintained pure-PHP client existed that came close in performance — the extension is a thin wrapper over the ScyllaDB C/C++ driver, which means shard-aware routing and native binary protocol handling at C++ speed. A pure-PHP implementation would have been an order of magnitude slower. The realistic alternatives were: stay on PHP 7 until it became a security liability, or abandon PHP entirely and rewrite the service in another language.&lt;/p&gt;

&lt;p&gt;Staying on PHP 7 was not a permanent answer. Security patches. Framework compatibility. PHP 7 EOL was already behind us. Rewriting the service was months of work with no guarantee the result would be better. So I forked the driver and started reading Zend source.&lt;/p&gt;

&lt;h2&gt;
  
  
  learning the zend engine the hard way
&lt;/h2&gt;

&lt;p&gt;PHP extensions are C code that hooks into the Zend Engine through a set of macros and function tables. In principle that is not complicated. In practice the codebase I inherited had been written to support PHP 5, 6, and 7 simultaneously through a layer of compatibility macros with names like &lt;code&gt;PHP5TO7_ZEND_OBJECT&lt;/code&gt;, &lt;code&gt;PHP5TO7_ZEND_HASH_FOREACH_STR_KEY_VAL&lt;/code&gt;, and &lt;code&gt;PHP5TO7_ZVAL_MAYBE_DESTROY&lt;/code&gt;. There were dozens of them, some wrapping trivial one-liners, some hiding real semantic differences between PHP versions.&lt;/p&gt;

&lt;p&gt;The Zend Engine's object model changed significantly between PHP 7 and PHP 8. &lt;code&gt;zend_parse_parameters&lt;/code&gt; deprecated half its format specifiers. &lt;code&gt;get_properties&lt;/code&gt; and &lt;code&gt;get_gc&lt;/code&gt; handlers changed signatures. And the macros — originally written to smooth over the 5→7 transition — did not account for 8 at all.&lt;/p&gt;

&lt;p&gt;I fixed the compile errors. PHP 8.0 worked. Then 8.1. Then 8.2, which introduced new deprecations of its own — &lt;code&gt;__toString&lt;/code&gt; prototype mismatches, &lt;code&gt;zend_hash_sort&lt;/code&gt; API changes. Each minor version was another round of "which macro is lying to me now."&lt;/p&gt;

&lt;p&gt;This was also when I realized the original driver had been written in C, and that C was making the maintenance problem worse. Not because C is wrong for PHP extensions — it is perfectly valid — but because the compatibility macro layer had produced code that was almost impossible to read. You needed three mental translation steps to understand what any given function actually did at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  the decision i still regret
&lt;/h2&gt;

&lt;p&gt;In March 2023 I renamed every &lt;code&gt;.c&lt;/code&gt; file to &lt;code&gt;.cpp&lt;/code&gt;. All of them. In one pull request.&lt;/p&gt;

&lt;p&gt;The rationale made sense at the time: C++ gives you RAII, references that cannot be null, &lt;code&gt;std::string&lt;/code&gt; instead of manual &lt;code&gt;char *&lt;/code&gt; arithmetic, better type inference. The Zend API is a C API but you can call it from C++ with &lt;code&gt;extern "C"&lt;/code&gt;. ScyllaDB's own C/C++ driver — which we wrap — is C++ already. The pieces would fit together.&lt;/p&gt;

&lt;p&gt;What I underestimated was how much of the existing code relied on C idioms that do not translate cleanly to C++: implicit &lt;code&gt;void *&lt;/code&gt; casts everywhere, VLAs, designated initializers used in ways the C++ standard does not permit. The conversion produced warnings. Some warnings were bugs. Tracking down which was which took months.&lt;/p&gt;

&lt;p&gt;And the toolchain story got harder. Finding people who were comfortable contributing to a PHP extension was already difficult. Finding people comfortable with a PHP extension written in C++ that wraps a C++ driver and uses CMake — that narrowed the pool considerably.&lt;/p&gt;

&lt;p&gt;I would have been better off keeping C and just deleting the compatibility macro layer one file at a time. The abstraction was the problem, not the language.&lt;/p&gt;

&lt;h2&gt;
  
  
  cmake and the build system swamp
&lt;/h2&gt;

&lt;p&gt;The original extension used &lt;code&gt;config.m4&lt;/code&gt; — the PHP build system, based on autoconf. It worked, but it was opaque, hard to extend, and did not give you any way to express modern dependency relationships cleanly. Adding the ScyllaDB C/C++ driver as a bundled dependency was painful.&lt;/p&gt;

&lt;p&gt;I replaced it with CMake. That decision I do not regret — CMake at least has documentation you can actually read. But learning it was its own project. The PHP extension build model assumes phpize generates your Makefile. CMake generates its own build system and has to replicate what phpize does: finding the PHP headers, setting the correct CFLAGS, linking against the right interpreter library, installing the &lt;code&gt;.so&lt;/code&gt; to the right extension directory.&lt;/p&gt;

&lt;p&gt;Getting that working took several iterations. Getting it to work on GitHub Actions — across PHP 8.1, 8.2, 8.3, multiple Linux distributions, both thread-safe and non-thread-safe builds — took longer. The CI was a second project hiding inside the first one. There were commits like &lt;code&gt;fix(release): release fixed&lt;/code&gt; appearing three times in a row because I had been testing the release workflow by pushing tags and watching it fail.&lt;/p&gt;

&lt;p&gt;libuv was its own subplot. The ScyllaDB C++ driver depends on libuv. Building libuv as a static dependency and linking it into a shared &lt;code&gt;.so&lt;/code&gt; requires &lt;code&gt;-fPIC&lt;/code&gt; everywhere. Miss it in one place — the C++ driver's own cmake build, for instance — and you get a linker error that looks completely unrelated to the actual cause. I have that error memorized.&lt;/p&gt;

&lt;h2&gt;
  
  
  daniel and he4rt
&lt;/h2&gt;

&lt;p&gt;At some point in 2023, Daniel — danielhe4rt — joined ScyllaDB and started looking at the PHP driver situation. He found the fork, sent a message, and we started working on it together under the he4rt organization.&lt;/p&gt;

&lt;p&gt;Working with Daniel changed the pace of the project. He had a different focus — documentation, making the thing actually approachable for people who were not deep in the Zend internals — which complemented the low-level work I was doing. The test suite migrated from Behat to Pest. The README became legible. The he4rt organization became the home for the project.&lt;/p&gt;

&lt;p&gt;The driver got PHP 8.3 support in late 2023. PIE packaging in early 2025. macOS and Apple Silicon in 2026. Each of those required fixing something that had been quietly broken for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  where it is now
&lt;/h2&gt;

&lt;p&gt;The codebase is functional but not clean. There are still hundreds of &lt;code&gt;PHP5TO7_*&lt;/code&gt; macros in the source — the same ones that caused problems in 2022 — because removing them safely requires understanding each one individually and verifying the replacement compiles and behaves correctly across every supported PHP version. We are doing this in staged waves. Wave 1 is done. Wave 2 is in progress.&lt;/p&gt;

&lt;p&gt;The PHP stubs — &lt;code&gt;.stub.php&lt;/code&gt; files that describe the extension's public API and generate the argument info tables that PHP uses for reflection, named arguments, and IDE support — are about 70% complete. The remaining classes have hand-written arginfo that was correct for PHP 7 and may or may not be correct now.&lt;/p&gt;

&lt;p&gt;PHP 8.5 is coming. Some of the GC handler signatures changed again. Those fixes are already in.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i want to do next
&lt;/h2&gt;

&lt;p&gt;AI tooling has made this kind of mechanical refactoring much faster than it used to be. Working through the macro purge with an AI assistant that understands the Zend API has compressed weeks of careful reading into days. I want to spend more structured time on the project this year — not just fixing what's broken when someone files an issue, but actually finishing the cleanup work that has been sitting in the backlog.&lt;/p&gt;

&lt;p&gt;That means completing the stub coverage, removing the remaining PHP5TO7 macros, getting the full test suite green across PHP 8.2 through 8.5, and publishing proper releases on a predictable schedule. The PIE packaging is already there. The GitHub Actions pipeline works. The pieces exist; they need to be connected.&lt;/p&gt;

&lt;p&gt;The driver is useful. People are using it — in Serbia, in Brazil, in teams I have never talked to who found it through Packagist. That matters enough to do the job properly.&lt;/p&gt;

&lt;p&gt;There is also a series of posts I want to write alongside this work. The PHP extension ecosystem has almost no approachable documentation for people starting from scratch in 2025. Most tutorials assume autoconf, PHP 7, and C89. I want to write about building a PHP extension from zero using C23 and CMake — including the bridge between CMake and the &lt;code&gt;config.m4&lt;/code&gt; build system that &lt;code&gt;phpize&lt;/code&gt; expects, which is the part nobody documents clearly. And about publishing it on PIE, the modern PHP extension installer that finally makes distributing compiled extensions sane.&lt;/p&gt;

&lt;p&gt;Beyond C, the official stance is that PHP extensions must be written in C or C++. That is technically true — the Zend API is a C API — but it is not the whole story. Rust can call C APIs. Zig can call C APIs. You can write the glue layer in C and implement the actual logic in whatever compiled language you want. I plan to write about that: what it actually takes to build a PHP extension in Rust, in Zig, in anything that can produce a shared library and link against &lt;code&gt;libphp&lt;/code&gt;. The ecosystem deserves more than one path.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it isn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It is not an official ScyllaDB product — even though I now work at ScyllaDB. It started as a community fork of an abandoned DataStax project and that is still what it is. Nothing here represents ScyllaDB's roadmap or commitments.&lt;/li&gt;
&lt;li&gt;It does not have a test suite that covers every API surface. Large sections of the schema metadata API are untested.&lt;/li&gt;
&lt;li&gt;It does not have a stable ABI across PHP minor versions. You recompile for each PHP version. This is true of all PHP extensions, but worth stating.&lt;/li&gt;
&lt;li&gt;The C-to-C++ migration added real complexity. Contributing to this extension requires understanding both the Zend C API and C++17. That is a higher bar than it should be.&lt;/li&gt;
&lt;li&gt;The macro cleanup is not done. There are correctness bugs hiding in the legacy layer. We find them by running the test suite, and the test suite does not cover everything yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is in a better place than it was in 2022. It is not where I want it to be. I am still working on it.&lt;/p&gt;

</description>
      <category>php</category>
      <category>scylladb</category>
      <category>cassandra</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
