PHP client for the Macula SDK wire protocol
Status, 2026-08-30 — feature-complete, live-verified end to end
(PHP → ext-ffi → Go C ABI → QUIC → a real production station), matching
macula-go and
macula-rust:
handshake, unary RPC, PubSub, content transfer, and streaming RPC, every
primitive in both caller and provider roles, plus direct-dial (resolve a
procedure via the mesh DHT and dial its serving station in one hop,
plain and cert-chain-authorized), UCAN (mint/verify/introspect and
policy-gated serving), and RPC telemetry auto-facts (no extra code needed
on this side -- they fire automatically underneath call()/
serveWaitForCall(), inherited straight from macula-go).
Two capabilities present in macula-go are intentionally NOT exposed
here: periodic re-advertise and the supervised PubSub wrapper
(RunPublisher/RunSubscriber) are both background-loop-shaped APIs on
the Go side. A PHP process can trivially host the same external behavior
itself -- call advertiseDirect()/advertise() again on your own
schedule inside a while loop for the former, and loop subscribe() +
recvEvent() yourself for the latter -- so wrapping a second, Go-side
loop across the FFI boundary would only add callback plumbing this SDK's
synchronous design otherwise avoids entirely, not new capability.
A PHP client for the Macula wire
protocol. Architecturally this is a thin binding, not a third from-scratch
protocol port: macula-go already has a complete, live-verified
implementation of the wire protocol with a plain blocking API (no
goroutines/channels required of the caller) — a close match for PHP's own
default blocking-call execution model. This repo wraps that existing SDK
as a C shared library and loads it from PHP via ext-ffi, rather than
re-implementing CBOR/QUIC/Ed25519/frame signing a third time.
Rust's own mobile bindings (macula-rust-ffi) took the analogous
approach for Kotlin/Swift via UniFFI — no UniFFI backend exists for PHP,
so this repo hand-builds a much simpler synchronous C ABI directly
(PHP calls a blocking C function; the Go side blocks on the QUIC
operation internally and returns), skipping the async-callback plumbing
UniFFI needs for Kotlin coroutines / Swift async entirely — with one
exception: unary-RPC provider dispatch needs a real rendezvous (a PHP
handler runs between "a CALL arrived" and "send the reply"), which
this repo builds as a goroutine + channel pair on the Go side, split
into two PHP-facing calls (Session::serveWaitForCall() returning a
PendingCall, then PendingCall::replyResult()/replyError()) — see
Provider dispatch below.
This SDK is PHP. The only thing Go is used for is compiling one shared
library (libmacula.so) that PHP loads at runtime — you run one build
command and never touch Go again. If you don't have Go installed:
- Any OS: download the installer from go.dev/dl and follow its instructions, or
- macOS:
brew install go - Debian/Ubuntu:
sudo apt install golang-go(check the version is ≥ 1.25 — if your distro's package is older, use the go.dev installer instead) - Arch:
sudo pacman -S go - Fedora:
sudo dnf install golang
Verify with go version, then follow Quick start below
— cd cabi && go build ... is the one and only Go command you'll ever
run. A future release may ship prebuilt libmacula.so binaries via
Composer so this step isn't needed at all; for now, building it
yourself is a single command that takes a few seconds.
| Primitive | Caller | Provider | Notes |
|---|---|---|---|
| Handshake (CONNECT/HELLO) | ✅ | — | |
| Unary RPC (CALL/RESULT/ERROR) | ✅ | ✅ | Provider via a goroutine+channel rendezvous, see below |
| PubSub (PUBLISH/SUBSCRIBE/EVENT) | ✅ | ✅ | A subscriber gets its own publish, verified live |
| Content transfer (single-block + chunked) | ✅ | ✅ | Content-addressed, BLAKE3/SHA-256, Merkle-verified |
| Streaming RPC (STREAM_OPEN/DATA/END/REPLY) | ✅ | ✅ | Provider via streamAccept() — no rendezvous needed, unlike unary RPC |
| RPC advertise/unadvertise | ✅ | — | |
Direct-dial RPC (resolveDirect/callDirect/advertiseDirect) |
✅ | ✅ | Resolves a procedure via the mesh DHT and dials its station in one hop; WithCertChain variants add managed-realm org authorization |
Direct-dial streaming/content (streamOpenDirect/putDirect/getDirect) |
✅ | — | getDirect only resolves content a station/relay announced — a leaf identity can't legitimately publish a content_announcement, matching macula-go's own scope |
| UCAN (mint/verify/introspect, policy-gated serving) | ✅ | ✅ | Ucan::create/verify/decode; Session::callWithUcan/serveWaitForCallGated — a rejected caller is refused before any PHP handler runs |
RPC telemetry facts (rpc.sent_v1/rpc.completed_v1/rpc.received_v1/rpc.replied_v1) |
✅ | ✅ | Automatic, no extra call needed — inherited from macula-go's Session.Call/ServeOneCallGated |
cabi/ Go module, builds libmacula.so via `go build -buildmode=c-shared`.
Plain consumer of macula-go's public API -- no changes
needed to macula-go itself. You never edit this unless
you're adding a new wire primitive.
cabi/testc/ A standalone C smoke test, independent of PHP -- proves the
cgo boundary and a real handshake work without needing PHP
installed at all.
src/ PHP composer package -- this is what you actually use.
Binding.php loads libmacula.so via FFI::cdef(); KeyPair,
Session, Value, CallResponse, Event, StreamHandle/
StreamItem/StreamReply/StreamOpenInfo, PendingCall are
the public API.
examples/ One runnable script per wire primitive, against the real
production fleet -- see Examples below.
tests/ Offline PHPUnit suite -- no network, no live station.
See Testing below.
cd cabi && go build -buildmode=c-shared -o libmacula.so . && cd ..
composer install
php examples/01_handshake.php<?php
require 'vendor/autoload.php';
use Macula\KeyPair;
use Macula\Session;
use Macula\Value;
$identity = KeyPair::generate(); // puzzle-hardened by construction
$session = Session::connect('station-de-frankfurt.macula.io', 4433, $identity);
$response = $session->call('io.macula.echo', str_repeat("\x00", 32), Value::text('hello'));
printf("is_error=%s payload=%s\n", $response->isError() ? 'true' : 'false', $response->payload()->asText());
$session->close();Value is a restricted mirror of macula-go's cbor.Value --
Null/Int/Bytes/Text/Float (no List/Map yet, the same v1
cut macula-rust-ffi's own FfiValue made — a payload needing
structure today should be encoded as Bytes).
One script per wire primitive, each runnable on its own against the
real production fleet (station-de-frankfurt.macula.io) — read them in
order, they build on each other:
| # | File | Primitive |
|---|---|---|
| 1 | 01_handshake.php |
Identity + CONNECT/HELLO handshake |
| 2 | 02_call.php |
Unary RPC, caller role |
| 3 | 03_publish_subscribe.php |
PubSub: SUBSCRIBE → PUBLISH → EVENT |
| 4 | 04_content.php |
Content transfer: single-block and chunked put/get |
| 5 | 05_stream_open_caller.php |
Streaming RPC, caller role |
| 6 | 06_run_rpc_provider.sh |
Unary RPC, provider role (serveWaitForCall) — two processes |
| 7 | 07_run_stream_provider.sh |
Streaming RPC, provider role (streamAccept) — two processes |
Run any of 1–5 directly (php examples/02_call.php); 6 and 7 are
.sh scripts because the provider role genuinely needs two independent
connections — see Two-process pattern
for why that's two OS processes (06_rpc_provider_serve.php +
06_rpc_provider_call.php, 07_stream_provider_serve.php +
07_stream_provider_call.php) rather than one script.
cd cabi && go build -buildmode=c-shared -o libmacula.so . && cd ..
composer install
composer test # or: vendor/bin/phpunittests/ is an offline PHPUnit suite — no network, no live station,
runs in CI on every push. It's not testing everything the examples
above prove; it's testing what's actually testable without a real
station: Value construction (pure PHP), Binding's marshaling
helpers (valueFromParts(), cBytes() — the latter does load
libmacula.so and allocate a real C buffer, but never opens a
connection), and KeyPair lifecycle (generate()/nodeId()/free()
against the real compiled library — Ed25519 keygen and S/Kademlia
puzzle-hardening are entirely local computation, no network involved
at all). Everything that needs an actual CONNECT/HELLO handshake —
which is most of the wire protocol — is proven by the
examples instead, run manually against the real
production fleet, the same live-verification discipline
macula-go and macula-rust both use.
$session->advertise('math.double', $realm);
$pending = $session->serveWaitForCall(30000); // blocks for the next inbound CALL
$n = $pending->payload()->intValue;
$pending->replyResult(Value::int($n * 2));Unlike every other primitive here, this can't be a single blocking
call: macula-go's own Session.ServeOneCall takes a Go closure
as the handler and runs "wait, invoke, reply" as one atomic operation
— PHP has nothing to hand across the FFI boundary in place of that
closure. cabi/serve.go splits it instead: macula_serve_wait_for_call
spawns a goroutine running the real ServeOneCall, whose handler
closure blocks on a Go channel; macula_serve_wait_for_call itself
blocks on a second channel until that handler fires, then returns a
PendingCall handle. PendingCall::replyResult()/replyError()
sends PHP's answer back down the first channel and blocks until
ServeOneCall has actually sent the wire frame. Exactly one reply
call is required per PendingCall — dropping one without replying
leaks the waiting goroutine, since there's no cross-boundary way to
notice "PHP gave up" and unblock it (PendingCall warns via
trigger_error if this happens).
fork() after a cgo-backed shared library (libmacula.so) is loaded
is not safe for continued execution in the child — confirmed against
golang/go#15538: fork()
only duplicates the calling OS thread, not the extra threads Go's own
scheduler/netpoller depend on, so a forked child gets a broken copy of
the Go runtime (silent I/O stalls, not a crash you'd notice
immediately — this repo hit it directly building the streaming
example). The standard fix is "fork() only safe when immediately
followed by exec()"; this repo's own provider-role examples use two
real OS processes instead of pcntl_fork():
bash examples/06_run_rpc_provider.sh
bash examples/07_run_stream_provider.shBoth spawn a provider process in the background, give the station a
moment to register its advertise(), then run a caller process against
it — the realistic shape a real deployment takes anyway (a provider
daemon process, separate from whatever calls it), not a workaround
adopted only for testing.
A second real gotcha found running these: Session::close() tears
down the whole QUIC connection immediately (macula-go's own
Session.Close has no drain step). For unary RPC this is harmless —
the caller only returns from call() after actually receiving the
RESULT frame, so by the time either side closes, the exchange is
already confirmed complete. For streaming, closeSend() is
fire-and-forget (no acknowledgment the peer's recv() has seen the
STREAM_END yet), so a provider closing its session immediately after
closeSend() can race the frame it just queued, and the caller
sometimes sees a hard connection-level EOF instead of a graceful
end-of-stream — reproduced directly building this repo (07's
provider failed intermittently, 06's never did, and the RPC-vs-
streaming acknowledgment difference above is exactly why).
This is inherent to closeSend()'s fire-and-forget design, not
something a fixed delay can fully eliminate (a longer usleep() only
narrows the window, and did not fully close it under stress testing —
23/24 runs clean, one still racing). 07_stream_provider_serve.php
keeps a short usleep() before close() as a courtesy that helps in
the common case; 07_stream_provider_call.php is the actual fix —
it treats a connection-level EOF on the final recv() as an accepted,
documented outcome rather than a failure, since by that point the
real data has already arrived and both shapes mean the same thing
("nothing more is coming"). A real long-lived provider daemon
wouldn't hit this at all, since it has no reason to close its
connection right after every response.
Handles: every Go value that crosses the boundary (identities,
sessions, call responses, events, streams, pending calls) is wrapped as
a runtime/cgo.Handle — an opaque uintptr_t PHP holds and passes
back, never touching the Go value directly. Payloads: Values cross as
five flat scalar parameters (kind, int_val, bytes_val/bytes_len,
float_val) rather than a struct, deliberately — a struct's memory
layout has to match byte-for-byte between Go's cgo-generated version and
PHP's own hand-written FFI::cdef() declaration, and getting that wrong
fails as a segfault, not a type error; flat scalar parameters carry no
such risk. Session holds a PHP reference to the KeyPair it was
connected with (and StreamHandle to the KeyPair it was
opened/accepted with) for its whole lifetime — both because
macula-go's own signing operations need the identity again on every
call, and because that reference keeps PHP's refcounting GC from
freeing the identity out from under a still-open session/stream (their
destruction order isn't otherwise guaranteed). Errors: functions that
can fail take a char** err_out — on failure they malloc a C string
into *err_out that the caller frees via macula_free_string;
Binding::withErrOut() wraps this pattern once so the public classes
never touch it directly.
Live-verified, 2026-08-28, every primitive, against
station-de-frankfurt.macula.io:
$ php examples/01_handshake.php
identity node_id: 912e278c91bc32aa8834b556381ba1ccac829a0d5884fa1bf8d29915012c108b
accepted: true
station node_id: 808d48be8780338f9739b96b17a09c086caea2fdac878b28e8b89fc8d72592a6
OK
$ php examples/02_call.php
OBSERVED: got an ERROR (expected for a nonexistent procedure): code=1 name=unknown_next_peer
$ php examples/03_publish_subscribe.php
OBSERVED: received our own EVENT back: topic=... seq=1 delivered_via=direct payload=hello from macula-php
$ php examples/04_content.php
put single block: mcid=... single-block round trip OK
put chunked: mcid=... size=536633 chunked round trip OK
$ php examples/05_stream_open_caller.php
no reply within 5s, as: stream: peer aborted the stream: unknown_next_peer (procedure not advertised)
$ bash examples/06_run_rpc_provider.sh
[provider] serving CALL for procedure=...
[caller] got RESULT 42
$ bash examples/07_run_stream_provider.sh
[provider] accepted stream_open for procedure=... mode=0
[caller] received chunk: hello from the provider
[caller] received Eof
Every empirical finding here matches macula-go's and
macula-rust's own live results exactly (unknown_next_peer for
both an un-advertised CALL and an un-advertised STREAM_OPEN,
delivered_via=direct for a subscriber receiving its own publish) —
three independent implementations, now four, agreeing not just on wire
bytes but on live protocol behavior.
- PHP ≥ 8.1 with
ext-ffiandext-sodiumenabled (ext-pcntlis not required — this repo's own provider-role examples orchestrate two processes via plain shell&/wait, not PHP-level forking).ext-ffiis not always enabled by default in distro PHP builds — checkphp -m | grep FFI; if missing, PHP needs to be (re)built with--with-ffi(not--enable-ffi— that flag doesn't exist and is silently ignored byconfigure, which was this repo's own first mistake building it). - Go ≥ 1.25 and a C compiler (
cgorequirement) to buildcabi/— see New to Go? above if you don't have it installed; you'll run one build command and never touch Go again. - Composer.
Built and live-verified, feature-complete: identity, CONNECT/HELLO,
unary RPC (both roles), PubSub, content transfer, streaming RPC (both
roles) — the same wire-protocol scope macula-go and
macula-rust cover, all driven through the full real stack from
genuine PHP.
Not built, and out of scope for a leaf SDK entirely (not a gap):
DHT/HyParView/Plumtree gossip primitives — station-to-station overlay
membership/broadcast, never a leaf-client concern; macula-go's
own spec says so explicitly.
| Project | Description |
|---|---|
| macula-go | The Go SDK this repo binds to |
| macula-rust | The Rust port — mobile bindings via UniFFI |
| macula | The reference SDK (Erlang/OTP) |
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
The PHP emblem in this README's header logo is the official PHP
logo, © Colin Viebrock,
licensed
CC BY-SA 4.0 — used
and redistributed here (as part of assets/macula-php-full-{dark,light}.svg)
under that license's own attribution and share-alike terms, distinct
from this repo's own dual Apache-2.0/MIT license above.
Built with the BEAM's protocol, ported to PHP — sponsor the work if this saved you some time