A Forjed Project
A native PHP extension, written in Rust with ext-php-rs, that
captures Laravel/PHP telemetry (dumps, queries, jobs, views, requests, logs, cache) and
streams it as newline-delimited JSON to Yerd's loopback dump server for display in
Yerd's GUI "Dumps" window. It is the open-source equivalent of Laravel Herd's proprietary
dump extension.
It is consumed by Yerd, not installed by end users: the Yerd daemon downloads the
matching artifact per installed PHP version and loads it into PHP-FPM. There is no
composer/pecl step.
See RELEASING.md for the release process.
The extension registers a single zend_observer fcall observer (modern, cached per
function definition) plus RINIT/RSHUTDOWN hooks. It observes:
| Category | Observed symbol |
|---|---|
dump |
Symfony\Component\VarDumper\VarDumper::dump (the chokepoint dump()/dd() funnel through) |
query |
PDO::{exec,query}, PDOStatement::{execute,bindValue,bindParam,fetchAll} (framework-agnostic); emits sql + bindings + sql_full (interpolated) + row_count |
job / view / cache / log |
Illuminate\Events\Dispatcher::dispatch (event class → category) |
request |
assembled at RSHUTDOWN: uri/method/ip from $_SERVER (Yerd's proxy sets the real REQUEST_URI incl. query string), status from SAPI globals |
http |
curl_exec (also covers Guzzle / PSR-18 clients that use the curl handler); reads curl_getinfo for url/status/time |
Every observer body runs behind a panic firewall; all telemetry is best-effort and must never break the user's application.
ext-php-rs produces a regular PHP extension (the modern zend_observer API works
from a normal extension's MINIT). It is therefore loaded with -d extension=<path>,
not -d zend_extension=<path>. Yerd must wire extension=.
The rest of the contract is unchanged: NDJSON loopback transport, the frame schema, the
yerd_dump.state_path INI directive, state.json, and the
yerd-dump-<minor>-<os>-<arch>.so artifact naming.
Outgoing HTTP telemetry is a new category and a contract change Yerd must mirror:
state.json: addfeatures.http(bool). When absent/false the extension does not observecurl_exec— fully backward compatible.- Dump server / GUI: add an
httpDumpCategory(+ counts + an "HTTP" tab). - Frame payload (
category: "http"):{ method, url, status (int), duration_ms (float) }—urlis the effective URL incl. query string;methodis best-effort (effective_method, empty on older curl).
Until Yerd ships these, set features.http=false (or omit it) and behaviour is unchanged.
The query payload now includes (all additive / backward-compatible):
sql_full— the statement with bound values interpolated for display (e.g.… WHERE "id" = 7 AND name = 'Ada'), alongsidesql(parameterized) +bindings. Display-only (never executed): strings quoted/escaped,NULL/bool handled, and?/:nameinside string literals left untouched. Yerd's GUI should showsql_fullfor the runnable query.row_count(int ornull) — writes use the affected-row count (PDO::execreturn value /rowCount()); reads are deferred and counted from the rows returned byfetchAll(so SELECTs report the real count, not the driver-dependentrowCount()). Reads fetched row-by-row or never fetched fall back torowCount()/null.
Laravel parameter binding: Eloquent binds via PDOStatement::bindValue() and calls
execute() with no args, so the extension accumulates bindValue/bindParam per
statement (correlated by object handle) to populate bindings and sql_full. Deferred
read frames keep their execute-time ts, so ordering is preserved even though they're
emitted at fetchAll (or flushed at request end).
Call-site resolution now skips all vendor/ frames and reports the user's app frame.
file/line are present on: dump, query, http, log, cache,
view, and job. Caveats: job events fire inside the queue worker during
execution, so their file:line is usually empty / not the dispatch site; view/cache
point at the app call that triggered them. request has no single call site (omitted).
Requires Rust (stable), a PHP with dev headers + php-config on PATH, and libclang
(for bindgen). On macOS the simplest source of headers is Homebrew (brew install php).
# Build
cargo build # debug → target/debug/libyerd_dump.{so,dylib}
cargo build --release # release (stripped, LTO)
# Lint + unit tests
cargo fmt --all --check
cargo clippy --all-targets -- -D warnings
cargo test --all
# End-to-end smoke (loads the .so into PHP, asserts frames on a sink)
cp target/debug/libyerd_dump.{so,dylib} /tmp/yerd_dump.so 2>/dev/null
EXT_SO=/tmp/yerd_dump.so PHP=$(which php) bash tests/integration/smoke.shecho '{"enabled":true,"port":2304,"features":{"dumps":true,"queries":true}}' > /tmp/state.json
nc -l 2304 & # or any loopback listener
php -d extension=$PWD/target/debug/libyerd_dump.so \
-d yerd_dump.state_path=/tmp/state.json \
tests/integration/fixtures/telemetry.phpsrc/
lib.rs MINIT/RINIT/RSHUTDOWN + #[php_module]
config.rs INI directive + state.json
observer.rs the single fcall observer (symbol classification + dispatch)
observers/ per-category logic: dumps, queries, events, http
frame.rs frame schema + serialized-line truncation
transport.rs non-blocking loopback TCP, connect-once-per-request
request.rs per-request state (thread-local) + emit
render.rs bounded, panic-safe Zval → text/HTML
caller.rs → see zend_util.rs (caller resolution)
zend_util.rs thin raw-Zend glue (arg/identity/caller)
panic.rs catch_unwind firewall
tests/integration/ TCP sink + fixture + smoke.sh
.github/workflows/ ci.yml, release.yml