In 2005 I wrote a PHP binding for libstatgrab and pushed it to PECL. The extension took CPU, memory, disk I/O, network, process, and user statistics from a cross-platform C library and exposed them to PHP as plain functions. I moved on to other things, libstatgrab kept evolving, PHP went through three major versions, and the binding sat untouched. By 2020 you could not build it against PHP 7 without patches. By PHP 8 it was effectively gone.
statgrab 2.0 brings it back. PHP 8.0 through 8.5, libstatgrab 0.92+, glibc Linux, musl, macOS, FreeBSD. The 2006 procedural API still works (sg_cpu_percent_usage, sg_memory_stats, sg_diskio_stats), there is a modern OO surface (Statgrab::cpu(), Statgrab::memory(), Statgrab::processes()), counters return as 64-bit int instead of the 2006 stringified %lld, and the BC bugs that were latent in the original (swapped page-stat keys, copy-pasted gid/egid fields, the flat name_list for users) are fixed.
A few things had to change to get there. One of them was upstreaming a memory leak fix to libstatgrab itself.
I do not pull old extensions forward by default. I learned why with lchash.
lchash is another extension I originally pushed to PECL in 2005: a string-keyed hash table for PHP, designed around the idea that PHP's array implementation, while general, carried ordering and bucket-reallocation overhead that pure key-value workloads did not need. Tighter memory footprint, faster lookup, simpler semantics ("first writer wins" like glibc hsearch(ENTER)).
I shipped a 1.0.0 modernization this week. Rebuilt the storage on top of klib khash, got it green on PHP 7.4 through 8.5 NTS and ZTS, added a proper OO surface (LcHash with $obj[$key] dimension access), wrote a benchmark script and let it run.
The numbers were not flattering. On a release build of PHP 8.4 NTS, glibc Linux x86_64, lchash takes 1.4x to 1.7x longer to insert and around 2x longer to look up than a native PHP array, at 10k, 100k, and 1M entries. That gap is structural. The PHP 7 array rewrite (Dmitry Stogov's packed-array work) and the 8.x JIT with inline caching produced a hash table the runtime treats as a first-class type, with opcode-level array-access specialization that no extension can match.
The flip: lchash uses 40 to 80 percent of the memory PHP arrays do at the same entry count, because keys and values are stored as refcount-shared zend_strings with no per-entry Bucket overhead. That makes the extension a real win for memory-tight workloads (a long-running CLI worker holding hundreds of thousands of small mappings), and it still has the legacy-compat and C-porting use cases it had in 2005. For general code, the answer is "just use a PHP array."
I shipped lchash 1.0.0 anyway, with the benchmark table at the top of the README and the use cases honestly scoped. The lesson is not "do not revive things." It is: the revival has to be honest about what changed underneath. PHP arrays grew up. lchash now competes on memory, not speed, and the README says so before anyone has to find out.
statgrab is not in that situation. PHP 8 arrays are not a substitute for cross-platform system stats. The choice today, for someone running PHP on a server who needs CPU, memory, or disk numbers, is still one of:
w, vmstat, df, ps and parse output that drifts between OS versions. fork+exec overhead per call./proc by hand. Linux-only, format keeps shifting between kernel releases, every file (meminfo, loadavg, diskstats, net/dev) has its own quirks.libstatgrab is the right primitive for option 4: a single C library that handles the per-OS path internally (Linux /proc, FreeBSD kvm, macOS host_* APIs) and exposes one typed surface. It has been in the Debian, Ubuntu, FreeBSD, and Homebrew package repositories for fifteen years. It just needed a PHP binding that worked on a current interpreter.
The 2006 binding was written against PHP 5, Zend 1, and 32-bit long. Most of the rewrite is mechanical: convert TSRM-style globals, replace Z_LVAL_PP patterns, switch to the typed parameter parsing macros. The non-mechanical parts were the BC quirks of the original API.
Four bugs were latent in the 2006 release.
Stringified counters. Memory totals, filesystem sizes, and CPU jiffies were returned as PHP strings. The reason was that 32-bit PHP could not hold a uint64_t, so the binding called snprintf("%lld", value) and shoved the string into a zval. Modern PHP runs on 64-bit zend_long. The 2.0 release returns these as plain integers. Callers comparing against numeric thresholds (if ($mem['total'] > 1_000_000_000)) now work correctly without an intval() wrapping every read.
Swapped page-stat keys. sg_page_stats() returned pages_in and pages_out swapped. Anyone who used the function and noticed inverted memory pressure curves probably worked around it locally. Fixed in 2.0.
gid and egid were copies of uid and euid. A copy-paste in the 2006 process-stats handler. Anyone filtering by group ID had been getting user IDs back. Fixed.
sg_user_stats() returned a flat list of usernames. This one is a libstatgrab change, not just a binding fix. The old library exposed a name_list array; the new library returns per-user records (login name, device, PID, login time, hostname). The new shape is strictly more useful. Callers reading name_list migrate to reading login_name from each record.
The full BC catalog is in the README. None of these are surprises if you read the libstatgrab CHANGELOG; they are surprises only if you remember the 2006 binding from when you last used it.
While running the new test suite under AddressSanitizer, statgrab's process-exit path leaked memory. Several allocations from libstatgrab's internal structures were never freed when the library shut down.
This is the kind of leak that does not matter for a long-running CLI process and does not show up in a typical request-response PHP cycle (the SAPI tears down the heap on each request). It matters for ASan-clean test runs, for valgrind-clean integration tests, and for anyone embedding libstatgrab in a long-lived process where shutdown order matters.
I traced it into libstatgrab itself. The library has a sg_shutdown() function but several globals were not on the cleanup path. I wrote the patch and opened it upstream against libstatgrab 0.92.1. As of writing it has not been released (the libstatgrab cadence is slow), so the statgrab repo carries a vendored copy of libstatgrab 0.92.1 with the local patch documented under vendor/libstatgrab/LOCAL_PATCHES.md.
If you build with --with-statgrab=bundled:
(cd vendor/libstatgrab && ./configure --enable-static --disable-shared --without-ncurses --with-pic && make)
phpize
./configure --with-statgrab=bundled
make
The resulting statgrab.so has no runtime dependency on libstatgrab.so. It links the patched copy in statically. For containerized or shared-hosting deployments this matters: you do not need to install libstatgrab on the target system, and you do not pick up whatever version the package manager happens to have. Once libstatgrab cuts a release with the patch, the bundled tree gets dropped or pinned to the released tarball.
The legal bookkeeping: vendored libstatgrab stays LGPL 2.1+, the extension code stays PHP-3.01. Static linking does not infect the extension because LGPL explicitly permits this with the standard provisions; the LICENSE files document both.
Most of the value is right here. The same PHP code that reads CPU usage on a glibc Linux box reads it on Alpine, on macOS, on FreeBSD. No conditional based on PHP_OS, no different parser per platform, no surprise when an Alpine container behaves differently from the dev box because /proc/meminfo formatting differs.
libstatgrab does the per-OS adaptation in C, once, with tests. Linux uses /proc and sysfs. FreeBSD uses the kvm interface. macOS uses host_statistics(), host_processor_info(), the BSD-style sysctl tree. The binding is the same shape regardless. From the PHP side:
$cpu = sg_cpu_percent_usage();
$mem = sg_memory_stats();
$load = sg_load_stats();
Those three calls return populated arrays on every supported OS. There is no "if Linux, do this; else do that" in your code.
The thing PHP people kept asking me about, when statgrab existed in 2006, was health endpoints. A small JSON endpoint that returns CPU usage, memory pressure, and load average so the load balancer or the orchestrator can decide whether to send traffic. Today that is more often the job of a Prometheus exporter or a sidecar agent, but the in-process version still has its place: when the application itself wants to know its own state.
Concrete examples that come up:
For each of those, shelling out is wrong (latency and parser fragility), and pulling in a stats daemon is overkill. statgrab fits the gap.
$mem = sg_memory_stats();
$load = sg_load_stats();
if ($load['min1'] > 8 || $mem['used'] / $mem['total'] > 0.9) {
$worker->throttle();
}
That is one library call per stat, no fork, no parsing. Same code on Linux, macOS, FreeBSD.
The OO surface is a thin layer for callers who prefer a class:
$sg = new Statgrab();
$top = $sg->processes(Statgrab::SORT_CPU, 10);
foreach ($top as $proc) {
echo "{$proc['proc_name']}\t{$proc['cpu_percent']}%\n";
}
PIE is the PHP Foundation's PECL successor and the recommended path on PHP 8.x:
pie install iliaal/statgrab
PECL still works for legacy installers:
pecl install statgrab
From source against system libstatgrab (Debian, Ubuntu, macOS via Homebrew, FreeBSD pkg) is documented in the README. The --with-statgrab=bundled path is for containerized environments and for picking up the unreleased leak fix.
statgrab joins a small set of native PHP extensions I keep maintained for the kinds of work pure-PHP libraries handle slowly:
One library call instead of forking a process. One typed surface instead of a per-OS parser. The same PHP code reading CPU, memory, and load on Linux, macOS, and FreeBSD without conditionals.
That is what the 2006 extension was reaching for, on PHP and libstatgrab versions that were not quite there yet. Both have caught up. The binding is the missing piece.
If you run PHP on a server and have ever shelled out to w or parsed /proc/meminfo by hand, give it a look.
Repository: github.com/iliaal/statgrab