Zero-Dependency System Metrics in Pure PHP
Why I built a system metrics library with no extensions, no Composer dependencies, and no C bindings. Reading /proc and sysctl directly gives you better container awareness than most monitoring agents.
PHP monitoring usually means installing a C extension (like php-sysinfo), pulling in a heavy APM agent, or shelling out to system commands and parsing stdout with regex. None of these are great in containers: extensions need compiling against your exact PHP build, APM agents add latency and memory overhead, and exec()-based approaches are fragile and slow.
I built System Metrics to solve this differently. It reads directly from /proc and /sys on Linux, and from sysctl/vm_stat on macOS. No PHP extensions. No Composer dependencies. One composer require and it works everywhere PHP runs.
use Cbox\SystemMetrics\SystemMetrics;
$overview = SystemMetrics::overview()->getValue();
echo "OS: {$overview->environment->os->name}\n";
echo "CPU Cores: {$overview->cpu->coreCount()}\n";
echo "Memory: " . round($overview->memory->usedPercentage(), 1) . "%\n";
Why "No Dependencies" Matters
In a typical production container, your PHP image is stripped down. You have the extensions you installed at build time and nothing else. Adding a monitoring library should not mean rebuilding your Docker image or installing system packages.
System Metrics has zero Composer dependencies and requires zero PHP extensions beyond what ships with stock PHP 8.3. On Linux it reads directly from /proc and /sys. On macOS it uses FFI bindings for native API access (memory via host_statistics, CPU via host_processor_info, uptime via sysctl) with exec-based fallbacks when FFI is unavailable. This means:
No
ext-posixorext-sysvsemrequirementsNo additional extensions. FFI ships with stock PHP 8.3
No version conflicts with other packages
Works identically in Alpine, Debian, Ubuntu, and Distroless containers
How It Reads System Data
Linux: The /proc Filesystem
On Linux, the kernel exposes system state as virtual files. System Metrics reads these directly:
CPU:
/proc/statprovides per-core time counters (user, system, idle, iowait, irq, softirq, steal, guest)Memory:
/proc/meminfogives total, free, available, buffers, cached, and swap in bytesLoad Average:
/proc/loadavgfor 1/5/15 minute loadNetwork:
/proc/net/devfor per-interface byte and packet countersStorage:
/proc/mountsanddffor filesystem usageContainers:
/proc/self/cgroupand/sys/fs/cgroup/for cgroup v1/v2 limits
Reading a file like /proc/meminfo is a single file_get_contents() call. No sockets, no RPC, no serialization. The kernel generates the content on read, so there's no disk I/O. It's a direct syscall.
macOS: FFI, sysctl, and Fallbacks
macOS doesn't have /proc. The library uses a composite source strategy: FFI bindings first, with exec-based fallbacks.
CPU: FFI via
host_processor_infofor per-core data, falling back tosysctl kern.cp_timeMemory: FFI via
host_statisticsfor page-level data, falling back tovm_statandsysctl hw.memsizeUptime: FFI via
sysctl kern.boottime, falling back to thesysctlbinaryLoad:
sysctl vm.loadavgEnvironment:
sw_versfor OS version,unamefor kernel info
FFI gives direct access to macOS kernel APIs without spawning processes, making it faster and more reliable. When FFI is disabled (some restricted environments), the library falls back to parsing command output via proc_open().
Type Safety with Immutable DTOs
Every metric comes back as a readonly PHP 8.3 class. You can't accidentally mutate a CPU reading:
readonly class CpuSnapshot {
public function __construct(
public CpuTimes $total,
public array $perCore,
public DateTimeImmutable $timestamp,
) {}
}
The CpuTimes object exposes raw tick counters with calculated helpers:
$cpu = SystemMetrics::cpu()->getValue();
echo "User: {$cpu->total->user} ticks\n";
echo "System: {$cpu->total->system} ticks\n";
echo "Total: {$cpu->total->total()} ticks\n";
echo "Busy: {$cpu->total->busy()} ticks\n";
These are monotonically increasing counters since boot. To calculate percentage usage, you need two snapshots and compute the delta:
// Convenience method: blocks for 1 second, returns the delta
$delta = SystemMetrics::cpuUsage(1.0)->getValue();
echo "CPU Usage: " . round($delta->usagePercentage(), 1) . "%\n";
echo "User: " . round($delta->userPercentage(), 1) . "%\n";
echo "System: " . round($delta->systemPercentage(), 1) . "%\n";
if ($busiest = $delta->busiestCore()) {
echo "Hottest core: #{$busiest->coreIndex}\n";
}
Or do it non-blocking by taking snapshots around your own work:
$snap1 = SystemMetrics::cpu()->getValue();
// ... your application logic ...
$snap2 = SystemMetrics::cpu()->getValue();
$delta = CpuSnapshot::calculateDelta($snap1, $snap2);
echo "Operation used " . round($delta->usagePercentage(), 1) . "% CPU\n";
Explicit Error Handling with Result<T>
System APIs can fail. A container might not have /proc/meminfo mounted. A restricted macOS sandbox might block sysctl. Instead of throwing exceptions that crash your monitoring code, every method returns a Result<T>:
$result = SystemMetrics::memory();
if ($result->isSuccess()) {
$mem = $result->getValue();
echo "Available: " . round($mem->availablePercentage(), 1) . "%\n";
} else {
error_log("Cannot read memory: " . $result->getError()->getMessage());
}
There's also a functional style for chaining:
SystemMetrics::cpu()
->onSuccess(function ($cpu) { echo "Cores: {$cpu->coreCount()}\n"; })
->onFailure(function ($err) { error_log($err->getMessage()); });
This pattern means your health checks and monitoring endpoints never throw uncaught exceptions in production.
Container Awareness
This is where most monitoring tools fall short. When you run inside Docker or Kubernetes, cat /proc/meminfo shows the host memory, not your container's cgroup limit. Your app thinks it has 64 GB when its container is capped at 512 MB, leading to incorrect scaling decisions and OOM kills.
System Metrics detects containers automatically and provides the real limits:
$container = SystemMetrics::container()->getValue();
if ($container->hasMemoryLimit()) {
$limitGB = round($container->memoryLimitBytes / 1024**3, 2);
echo "Container limit: {$limitGB} GB\n";
echo "Usage: " . round($container->memoryUtilizationPercentage(), 1) . "%\n";
}
It reads cgroup v1 (/sys/fs/cgroup/memory/memory.limit_in_bytes) and cgroup v2 (/sys/fs/cgroup/memory.max) depending on your kernel. The container() API gives you direct access to cgroup limits, while limits() provides a unified view:
$container = SystemMetrics::container()->getValue();
echo "Available CPU: {$container->availableCpuCores()} cores\n";
echo "Available Memory: " . round($container->availableMemoryBytes() / 1024**3, 2) . " GB\n";
echo "Memory utilization: " . round($container->memoryUtilizationPercentage(), 1) . "%\n";
Whether you are on bare metal or inside a Kubernetes pod with resource limits, System Metrics returns the correct effective values.
Real-World Use: Queue Worker Sizing
This is exactly why I built System Metrics: to feed accurate data into Laravel Queue Autoscale. The autoscaler uses it to cap worker counts at what the system can actually handle:
$cpu = SystemMetrics::cpu()->getValue();
$memory = SystemMetrics::memory()->getValue();
$workerCount = min(
$cpu->coreCount(),
(int) floor($memory->availableBytes / (256 * 1024 * 1024))
);
echo "Safe worker count: {$workerCount}\n";
No guesswork, no hardcoded limits, no surprises when your 2-core container tries to spawn 50 workers.
Performance
Static environment data (OS, kernel, architecture) is cached after the first call. Subsequent reads return the same object with zero I/O. Dynamic metrics (CPU, memory, network) always read fresh data, but the overhead is minimal: a single file read from /proc takes well under a millisecond.
The library ships with PHPStan Level 9 compliance, 89.9% test coverage, and PSR-12 code style. Full API documentation is in the package docs.
composer require cboxdk/system-metrics
// Sylvester Damgaard