<?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.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>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>
