cboxdk/system-metrics
Get real-time system metrics from Linux and macOS in pure PHP. No extensions or dependencies—just type-safe, readonly classes covering CPU, memory, storage, network, load, uptime, and container metrics with explicit Result-based error handling.
Install and check the platform gate first. The package requires PHP 8.3+ and Linux/macOS only — no Windows. In a Laravel app, run composer require cboxdk/system-metrics and immediately confirm your production and CI images satisfy the version floor, because composer install will fail on a 8.2 host that otherwise runs your app fine. Windows developers need Sail, Docker, or WSL; php artisan serve on a Windows host will not work.
composer require cboxdk/system-metrics
php -r "require 'vendor/autoload.php'; var_dump(Cbox\SystemMetrics\SystemMetrics::memory()->isSuccess());"
There is no Laravel service provider, no config file, and no facade alias to publish. It is a framework-agnostic library with a static entry point. Nothing appears under config/, and php artisan vendor:publish will not surface anything. Plan for that: you will wrap it yourself rather than configuring it.
Where to look first, in order:
docs/quickstart.md and docs/api-reference.md — the surface is wide (environment, CPU, memory, load, uptime, storage, network, container, limits, process) and you only need three or four of these.docs/architecture/result-pattern.md — every call returns Result<T>, never throws. This changes how you write every consumer.docs/platform-support/comparison.md — the macOS and Linux feature matrices differ; dev-on-Mac/prod-on-Linux is the common Laravel setup and you need to know which fields go null where.changelog/v3.0.0.md — a recent breaking release flipped CPU core fields from int to float and uncapped utilisation percentages. Anything you copy from a blog post or an LLM is likely v2-era code.First use case: a scheduled snapshot behind a /health/system route. Do not call this from middleware or a controller on every request. Compute a snapshot once a minute in the scheduler and serve it from cache.
// app/Console/Commands/SystemSnapshot.php
namespace App\Console\Commands;
use Cbox\SystemMetrics\SystemMetrics;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class SystemSnapshot extends Command
{
protected $signature = 'metrics:snapshot';
public function handle(): int
{
$result = SystemMetrics::overview();
if (! $result->isSuccess()) {
$this->error($result->getError()->getMessage());
Cache::forget('system:snapshot'); // stale data is worse than none
return self::FAILURE;
}
$o = $result->getValue();
Cache::put('system:snapshot', [
'os' => $o->environment->os->name.' '.$o->environment->os->version,
'cores' => $o->cpu->coreCount(),
'memory_pct' => round($o->memory->usedPercentage(), 1),
'load_1m' => $o->loadAverage?->oneMinute,
'uptime' => $o->uptime?->humanReadable(),
'container' => $o->limits?->isContainerized() ?? false,
'mem_limit' => $o->limits ? round($o->limits->memoryUtilization(), 1) : null,
], now()->addMinutes(2));
return self::SUCCESS;
}
}
// routes/console.php (Laravel 11+) or app/Console/Kernel.php
Schedule::command('metrics:snapshot')->everyMinute()->withoutOverlapping();
Route the read side off the cache, and treat a cold cache as "unknown", not "unhealthy", so a scheduler hiccup doesn't page anyone.
The static entry point is the single biggest ergonomic cost in Laravel. You cannot inject SystemMetrics, so tests hit real /proc and sysctl. Put a thin interface in front of it and bind it — everything downstream becomes mockable and swappable.
// app/Contracts/SystemProbe.php
interface SystemProbe
{
public function usedMemoryPercentage(): ?float;
public function loadPerCore(): ?float;
public function availableCpuCores(): ?float;
public function isContainerized(): bool;
}
// app/Services/SystemMetricsProbe.php
use Cbox\SystemMetrics\SystemMetrics;
class SystemMetricsProbe implements SystemProbe
{
public function usedMemoryPercentage(): ?float
{
$r = SystemMetrics::memory();
return $r->isSuccess() ? $r->getValue()->usedPercentage() : null;
}
public function loadPerCore(): ?float
{
$r = SystemMetrics::loadAverage();
return $r->isSuccess() ? $r->getValue()->perCoreNormalized() : null;
}
public function availableCpuCores(): ?float
{
$r = SystemMetrics::limits();
return $r->isSuccess() ? $r->getValue()->availableCpuCores() : null;
}
public function isContainerized(): bool
{
$r = SystemMetrics::environment();
return $r->isSuccess() && ($r->getValue()->insideContainer ?? false);
}
}
Bind it in a SystemMetricsServiceProvider. Return null on failure rather than propagating Result — your domain code should not import a metrics library's types.
This is the pattern that actually pays for the dependency day-to-day: reading load and memory is cheap, and it lets you turn off expensive optional work before the box falls over.
class DegradeHeavyFeatures
{
public function __construct(private SystemProbe $probe) {}
public function handle($request, Closure $next)
{
$load = $this->probe->loadPerCore();
if ($load !== null && $load > 1.5) {
config(['features.exports' => false, 'features.thumbnails' => false]);
Log::warning('feature.degraded', ['load_per_core' => $load]);
}
return $next($request);
}
}
Keep the threshold on perCoreNormalized-style values, not raw load average — a raw 4.0 means something completely different on 2 cores than on 32. If the package's normalisation method isn't available in your version, divide oneMinute by $cpu->coreCount() yourself.
In Kubernetes or Docker with an 200m/1500m CPU quota, the host core count is a lie. SystemMetrics::limits() gives you the fractional quota actually applied to the container — exactly what you want when provisioning Horizon or a supervisor worker count at deploy time.
// a provisioning command or entrypoint script
$result = SystemMetrics::limits();
$cores = $result->isSuccess() ? $result->getValue()->availableCpuCores() : 2.0;
$this->line('Suggested workers: ' . max(1, (int) floor($cores * 2)));
Same for memory: availableMemoryBytes() vs memory_get_usage(true) is the honest comparison inside a container, whereas memory_limit in php.ini may still say -1 or a host-sized number.
SystemMetrics::storage() is the practical use for the storage reader in a Laravel app — not capacity planning, but catching the day /var/log or storage/logs fills the volume.
$storage = SystemMetrics::storage()->getValue();
if ($storage->usedPercentage() > 85) {
Notification::route('slack', config('services.slack.ops'))->notify(new DiskFillingUp($storage));
}
Run it hourly in the scheduler, not per request. Note it reports filesystem-level usage, so on a container the root overlay often reflects the mount, not the writable layer — verify the number against df -h on your actual image before trusting the threshold.
Inside a queued job, ProcessMetrics::snapshot(getmypid()) gives you RSS for that worker, which is far more actionable than memory_get_peak_usage() when a job leaks through an unbounded collection or a large query.
public function handle(): void
{
$proc = ProcessMetrics::snapshot(getmypid())->getValue();
$rssMb = round($proc->resources->memoryRssBytes / 1024 ** 2, 2);
Log::withContext(['rss_mb' => $rssMb, 'job' => static::class]);
// ...
}
Wrap it in a base job class so you get the number on every job's log line for free.
SystemMetrics::cpuUsage(1.0) sleeps for a full second. That belongs in a scheduled command or a worker-side sampler, never in a controller, middleware, or Livewire action. For a dashboard, prefer the two-snapshot form so the sleep is decoupled from the request:
// metrics:snapshot --cpu
$before = SystemMetrics::cpu()->getValue();
sleep(2);
$after = SystemMetrics::cpu()->getValue();
$delta = CpuSnapshot::calculateDelta($before, $after);
Cache::put('system:cpu', [
'total' => round($delta->usagePercentage(), 1),
'user' => round($delta->userPercentage(), 1),
'system' => round($delta->systemPercentage(), 1),
'iowait' => round($delta->iowaitPercentage(), 1),
'busiest' => $delta->busiestCore(),
], now()->addMinute());
iowaitPercentage() and busiestCore() are the two fields that actually explain "why is the app slow" — a 90% iowait reading points at disk, not PHP.
If you use spatie/laravel-health or Laravel Pulse, consume this package inside a custom check rather than building a bespoke endpoint. Return failed() when memory or disk crosses a threshold, ok() otherwise, and let the health package own the route, caching, and notification plumbing.
class SystemResourcesCheck extends Check
{
public function run(): Result
{
$mem = SystemMetrics::memory();
if (! $mem->isSuccess()) {
return Result::failed('Memory metrics unavailable');
}
$pct = $mem->getValue()->usedPercentage();
return $pct > 92
? Result::failed("Memory at {$pct}%")
: Result::ok("Memory at {$pct}%");
}
}
Adding load and memory to Log::withContext once per request is genuinely useful when triaging an incident after the fact. Do it in a terminable middleware so it runs after the response, and accept that on Octane the values may be stale (see Gotchas).
Result<T> never throws, so nothing lands in your exception handler. Failures are silent unless you check. Use the fluent form for the low-ceremony case, and never call getValue() before confirming isSuccess() — the README's terse ->getValue() examples assume success:
SystemMetrics::cpu()
->onSuccess(fn ($cpu) => Cache::put('cores', $cpu->coreCount(), 3600))
->onFailure(fn ($err) => Log::debug('metrics.cpu.failed', ['error' => $err->getMessage()]));
Treat a failure as "metrics unknown", not "system broken". Every reader is filesystem- or shell-based and can legitimately fail inside hardened containers.
Windows is a hard no. No proc fallback, no stub. If any developer or CI runner is on Windows outside a container, the package will fail at runtime even after a successful install.
macOS reads via sysctl, vm_stat, sw_vers. If disable_functions blocks exec/shell_exec/proc_open — common on shared hosting and some hardened PHP images — the macOS path dies silently into a Result failure. On Linux it's /proc//sys reads, so open_basedir restrictions are the equivalent trap.
Static data caching plus Octane equals stale metrics. The docs advertise internal caching of static data (OS, architecture, core count). Under RoadRunner/Swoole/Octane those statics live for the worker's lifetime, so a container that gets resized from 500m to 2000m may keep reporting the old quota until workers recycle. If you use Octane, resolve the probe per-request or add a php artisan octane:reload to your resize runbook.
overview() is all-or-nothing. One failed sub-reader likely fails the whole composite. For a health dashboard where you'd rather show three of five panels, call the individual readers and degrade each independently. Use overview() only where you genuinely need a coherent single timestamp.
Nullable fields everywhere. loadAverage, uptime, and limits can be null in the overview. The README already nullsafe-chains them ($o->loadAverage?->oneMinute); follow that, and type-hint your own extractors as ?float / ?string. PHPStan level 9 in this package means your static analysis will correctly flag every unguarded access — let it.
v3.0.0 floats will bite copied code. cpuCores, currentCpuCores, and availableCpuCores() are float now, and cpuUtilizationPercentage() / memoryUtilizationPercentage() are no longer capped at 100% — over-quota bursts legitimately read 340%. Any int type hint, (int) cast, min(100, ...) assumption, or percentage-to-progress-bar component needs a min(100.0, ...) at the presentation layer. Pin the version explicitly in composer.json rather than floating on ^2 or ^3 until you've read the migration guide.
Don't sample per request. Reading /proc is cheap but not free, and the CPU-percentage path blocks for a second. Per-request sampling under load adds precisely the load you're measuring. One scheduled snapshot per minute into cache/Redis is the correct cadence; serve dashboards and health endpoints from that.
No injection point for config. There's no config/system-metrics.php, so thresholds, cache keys, and TTLs live in your own config file. Do this deliberately — it's the thing you'll want to tune per environment, and it makes the wrapper service testable.
Low adoption, single maintainer, 0 dependents. Seven stars and no downstream consumers means you are the de facto QA. Mitigations: wrap it behind your own interface (as above) so swapping to php-osx-style shell calls or a Prometheus sidecar is a one-file change; pin an exact version; and add a contract test asserting the fields you depend on still have the shapes you expect after any upgrade.
Testing on CI. GitHub Actions Linux runners expose /proc fine, but the numbers are shared-runner noise — assert on shape (non-null, sane range), not values. If the package ships fakes (the changelog references FakeSystemMetricsTest), use them for unit tests and reserve real reads for a single smoke test. Never assert a specific core count.
Prefer limits over memory in containers. SystemMetrics::memory() reflects the host's physical RAM; SystemMetrics::limits()->memoryUtilization() is cgroup-aware and reflects what your container can actually use. In Kubernetes these diverge by an order of magnitude, and using the wrong one makes your alerts meaningless.
How can I help you explore Laravel packages today?