fidry/cpu-core-counter
A tiny PHP utility for detecting CPU core counts, with logical and physical finders, built-in fallbacks, and memoized results. It also estimates cores available for parallel processes and lets you customize or reorder finders. Requires Composer.
composer require fidry/cpu-core-counter
There is no service provider, facade or config file — it is a plain PHP utility with a handful of classes under Fidry\CpuCoreCounter. In a Laravel app you introduce it yourself, and the right first move is to wrap it in a tiny singleton so nothing else in the codebase talks to the counter directly.
// app/Support/Cpu.php
namespace App\Support;
use Fidry\CpuCoreCounter\CpuCoreCounter;
final class Cpu
{
public static function count(int $fallback = 1): int
{
return app(CpuCoreCounter::class)->getCountWithFallback($fallback);
}
}
// app/Providers/AppServiceProvider.php (register())
$this->app->singleton(CpuCoreCounter::class, fn () => new CpuCoreCounter());
First use case worth wiring up: sizing parallel work. Laravel 11's Concurrency facade, Horizon supervisors, and any Process-pool style fan-out all benefit from a number that reflects the machine the code actually runs on rather than a hardcoded 8 in a config file.
use Illuminate\Support\Facades\Concurrency;
$chunks = Concurrency::run(
array_map(fn ($slice) => fn () => $this->process($slice), $slices)
);
Size the pool from Cpu::count() (or getAvailableForParallelisation() when the work is CPU-bound and you want headroom).
Where to look first, in order:
CpuCoreCounter::getCountWithFallback() — this is the call you will use 90% of the time. It never throws.CpuCoreCounter::getAvailableForParallelisation() — returns a result object whose availableCpus is a sensible concurrency number (it accounts for the fact you should not saturate every core).bin/diagnose.php and bin/execute.php in the vendor dir — run these before you trust the output anywhere.php vendor/fidry/cpu-core-counter/bin/diagnose.php # per-finder detail
php vendor/fidry/cpu-core-counter/bin/execute.php # what each finder returns
Never call the counter from a hot path. It shells out (nproc, lscpu, sysctl, wmic, reads /proc/cpuinfo) on first resolution. Results are memoized per CpuCoreCounter instance, so bind it as a singleton and resolve once. In config files resolved at boot, do the resolution in the provider, not inside config/*.php.
Expose it as config, not as a scattered call. Compute once at boot and publish into the config repository:
// AppServiceProvider::register()
$this->app->singleton(CpuCoreCounter::class, fn () => new CpuCoreCounter());
// AppServiceProvider::boot()
config([
'queue.connections.redis.worker_limit' => app(CpuCoreCounter::class)->getCountWithFallback(4),
]);
Horizon supervisors. The most natural Laravel fit — derive maxProcesses per supervisor from real cores instead of a per-environment hand-tuned number:
// config/horizon.php
'environments' => [
'production' => [
'supervisor-default' => [
'maxProcesses' => max(2, \App\Support\Cpu::count(4) * 2), // IO-bound: over-subscribe
'queue' => ['default'],
'balance' => 'auto',
],
'supervisor-heavy' => [
'maxProcesses' => max(1, \App\Support\Cpu::count(4)), // CPU-bound: 1 per core
'queue' => ['rendering'],
],
],
],
The rule of thumb that makes this worth doing: CPU-bound pools get ~count() workers, IO-bound pools get 2–4 × count().
Prefer getAvailableForParallelisation() for in-request fan-out. If you are forking with Concurrency, spatie/async, or a Process pool inside a web request, you want the headroom the package computes, not the raw core count — saturating every core from inside an FPM request is how you get latency cliffs.
$result = app(CpuCoreCounter::class)->getAvailableForParallelisation();
$pool = max(1, min($slices, (int) $result->availableCpus));
Custom finder chains for known deployments. When you know the platform, pin the finders so behaviour is deterministic and cheap:
use Fidry\CpuCoreCounter\CpuCoreCounter;
use Fidry\CpuCoreCounter\Finder\{CpuInfoFinder, HwLogicalFinder, DummyCpuCoreFinder};
$this->app->singleton(CpuCoreCounter::class, fn () => new CpuCoreCounter([
new CpuInfoFinder(),
new HwLogicalFinder(),
new DummyCpuCoreFinder(4), // terminal fallback, never throws
]));
Use FinderRegistry::getDefaultLogicalFinders() / ::getDefaultPhysicalFinders() (or CpuCoreCounter::getDefaultFinders()) as the base and array_filter out the ones that misbehave on your platform — that is the documented way to disable e.g. WindowsWmicFinder.
Logical vs physical. Default is logical (matches how PHP itself was built, and is what you want for worker counts). Switch to physical only when you are modelling genuinely physical throughput (licensing math, per-socket workloads, hardware reporting) via FinderRegistry::getDefaultPhysicalFinders().
Diagnostics belong in CI, not in prod debugging sessions. Add a small artisan command that runs the default finders and asserts a sane result, and call it in your deploy smoke test:
Artisan::command('cpu:diagnose', function () {
$counter = new CpuCoreCounter();
$this->line('cores: '.$counter->getCountWithFallback(1));
$this->line('parallel: '.$counter->getAvailableForParallelisation()->availableCpus);
$this->line($counter->trace()); // human-readable "what happened"
});
Run it against a container built from your real image, not your laptop.
getCount() throws. NumberOfCpuCoreNotFound is the failure mode on stripped containers, minimal Alpine images, or when proc_open/shell_exec are in disable_functions. In a Laravel app the exception will surface as a 500 at boot if you call it from a provider without a fallback. Always go through getCountWithFallback() or append a DummyCpuCoreFinder.disable_functions is the classic production break. Many hardened PHP images disable proc_open and shell_exec, which kills the executor-based finders. The package degrades to file-based finders (and then to your fallback), so a fallback is not optional. Check php -i | grep disable_functions in the deployed image.new CpuCoreCounter() twice = two resolution passes. Bind a singleton. Conversely, memoization means Octane workers keep the boot-time value forever — fine for static hardware, wrong if you resize containers under a long-lived worker.KUBERNETES_CPU_LIMIT with millicores (e.g. 1500m) is supported since 1.3.0, but a 500m limit means "half a core" — decide explicitly whether you want max(1, …). A CPU-limited pod does not get faster by spawning count() workers; it gets throttled. Cap your pools by the limit, not by the node's core count, and sanity-check with diagnose.php inside a pod.CpuAffinityFinder and online CPUs. Fixed in 1.4.1 — only online CPUs are counted. If you pin CPUs in Docker (--cpuset-cpus) and see wrong numbers, that is the finder to look at.FinderRegistry::get*Finders() is explicitly outside the BC promise. New finders can appear and the order can change in a minor release. If you depend on a specific order, build your own array and do not call the registry at runtime.trace() is your first debugger. When the count looks wrong, call CpuCoreCounter::trace() and compare against execute.php; it names the finder that answered. Guessing at finder order is a waste of an afternoon.container_cpu_cfs_throttled_periods in k8s).App\Support\Cpu wrapper as the single call site so swapping in a one-liner shell_exec('nproc') is trivial, and (c) always passing a fallback. The wrapper is your exit strategy.How can I help you explore Laravel packages today?