promphp/prometheus_client_php
PHP Prometheus client library that uses Redis, Predis, APCu, or in-memory adapters for client-side aggregation. Register counters, gauges, histograms, and summaries, then expose metrics in Prometheus text format.
composer require promphp/prometheus_client_php
Requires PHP ^8.2. You also need one of: ext-redis (+ a Redis server), predis/predis, ext-apcu, or a PDO DSN (MySQL/Postgres/SQLite).
The library does client-side aggregation, so every inc()/observe() is a storage write. In Laravel/FPM you have N ephemeral workers with no shared memory, so the adapter decides both correctness and cost:
| Adapter | Use when |
|---|---|
Prometheus\Storage\Redis |
Default choice. Several app servers / containers, or you can't rely on per-box state. Run Redis on the same host/network as the workers. |
Prometheus\Storage\RedisNg |
Same, but your Redis keyspace is large — it avoids KEYS during collection (added in 2.7). |
Prometheus\Storage\APCng |
Single box (or metrics only ever scraped per-box), lowest latency, no extra binary. |
Prometheus\Storage\InMemory |
Tests, artisan commands, one-shot scripts. Vanishes at process end. |
PDO |
No Redis available and volume is low (2.11+). |
If you're on multi-node with a load balancer, only Redis/PDO give you a coherent scrape. APC/APCng gives you per-node counters that Prometheus will sum across targets — which is often fine if you scrape every node.
There is no Laravel package here — no auto-discovered provider, no config file. You write it. Put a binding in a dedicated provider so the adapter is chosen in one place (see below), and never call CollectorRegistry::getDefault() from application code — that static singleton makes tests and Octane painful.
/metrics// routes/metrics.php (loaded by the provider)
Route::get('/metrics', function (CollectorRegistry $registry) {
$renderer = new RenderTextFormat();
$body = $renderer->render($registry->getMetricFamilySamples());
return response($body, 200, ['Content-Type' => RenderTextFormat::MIME_TYPE]);
})->withoutMiddleware(['web', 'auth']);
# prometheus.yml
scrape_configs:
- job_name: 'laravel'
metrics_path: /metrics
static_configs:
- targets: ['app-1:80', 'app-2:80']
This is the 20-minute win — request rate, error rate, latency, per route.
final class RecordHttpMetrics
{
public function __construct(private CollectorRegistry $registry) {}
public function handle(Request $request, Closure $next)
{
$start = microtime(true);
$response = $next($request);
try {
$route = $request->route()?->getName() ?? 'unmatched';
$labels = [$request->method(), $route, (string) $response->getStatusCode()];
$this->registry
->getOrRegisterCounter('http', 'requests_total', 'HTTP requests', ['method', 'route', 'status'])
->inc($labels);
$this->registry
->getOrRegisterHistogram('http', 'request_duration_seconds', 'HTTP duration', ['method', 'route'], Histogram::exponentialBuckets(0.005, 2, 12))
->observe(microtime(true) - $start, [$request->method(), $route]);
} catch (\Throwable $e) {
report($e); // never let instrumentation 500 a request
}
return $response;
}
}
Where to look first in the repo: examples/ for working end-to-end scripts, README.APCng.md if you're going the APCu route, and the release notes for behaviour changes (2.9 value errors rendered as comments, 2.8 verbose exceptions, 2.7 lock elimination on histogram/gauge updates). The PushGateway is not in this package since 2.0 — that's promphp/prometheus_push_gateway_php.
final class MetricsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(CollectorRegistry::class, function () {
return new CollectorRegistry($this->makeStorage(), sorting: true);
});
}
public function boot(): void
{
if ($this->app->runningInConsole() === false || config('metrics.enabled')) {
Route::middleware('metrics')->group(base_path('routes/metrics.php'));
}
}
private function makeStorage(): StorageAdapter
{
$prefix = config('metrics.prefix'); // e.g. 'acme_prod_'
return match (config('metrics.adapter')) {
'redis' => (function () use ($prefix) {
// MUST run before `new Redis()` — these are static defaults.
Redis::setDefaultOptions([
'host' => config('metrics.redis.host'),
'port' => (int) config('metrics.redis.port'),
'password' => config('metrics.redis.password'),
'timeout' => 0.1,
'read_timeout' => 1.0,
'persistent_connections' => false,
]);
Redis::setPrefix($prefix);
return new Redis();
})(),
'apcng' => (function () use ($prefix) {
APCng::setPrefix($prefix);
return new APCng();
})(),
'inmemory' => new InMemory(),
};
}
}
getOrRegister* in hot loopsgetOrRegisterCounter() hits the registry map (and, for Redis, potentially reads metadata). Hoist collectors into constructor properties or a small Metrics service class injected where needed:
final class AppMetrics
{
private Counter $signups;
public function __construct(CollectorRegistry $registry)
{
$this->signups = $registry->getOrRegisterCounter(
'app', 'signups_total', 'Completed signups', ['plan']
);
}
public function signup(string $plan): void
{
$this->signups->inc([$plan]);
}
}
$request->route()->getName() (users.show) keeps cardinality at O(routes). $request->path() (users/8341) is an outage waiting to happen.*_seconds not *_ms, *_bytes, *_total for counters. This library does not append _total for you — name it yourself, otherwise rate() on a counter breaks Grafana dashboards.getOrRegisterCounter('billing', 'invoices_total', ...) renders as billing_invoices_total. Use '' for a global.0.99 you see is an average of samples, not a real tail. Histograms aggregate correctly in Prometheus (histogram_quantile).Long-running workers keep their registry alive across jobs — that's fine with Redis, and it's where the adapter's shared state pays off (a job counter survives the worker restarting). Instrument in a job middleware:
public function handle($job, $next)
{
$this->jobs->inc(['attempt']);
try { $next($job); }
catch (\Throwable $e) { $this->failed->inc([get_class($job)]); throw $e; }
}
For cron/batch, use a gauge with a timestamp — this is the pattern that catches "the scheduler silently stopped":
$registry->getOrRegisterGauge('cron', 'last_success_timestamp_seconds', 'Last successful run', ['task'])
->setToCurrentTime([$task]);
Alert on time() - cron_last_success_timestamp_seconds > 900.
protected function setUp(): void
{
parent::setUp();
$this->app->instance(CollectorRegistry::class, new CollectorRegistry(new InMemory()));
}
Then scrape your own endpoint and assert on the text body — that tests the real rendering path, label values, _bucket/_sum/_count suffixes, escaping, everything:
$this->get('/metrics')->assertOk()->assertSee('http_requests_total{method="GET",route="home",status="200"} 1');
Business metrics belong in events/listeners (OrderPlaced → increment orders_total{status}). It keeps the collector definitions in one auditable place and makes it obvious to a reviewer what cardinality you're introducing.
setDefaultOptions() / setPrefix() are static and order-dependent. They must run before the first new Redis() in the process. If your binding builds a connection lazily and something else touches Redis first, your prefix silently applies to the wrong client — or not at all. Also: Redis::setPrefix() and Predis::setPrefix() share the same static prefix. Using both adapters with different prefixes in one app is explicitly unsupported.
Shared Redis = namespaced prefix, always. Two apps sharing a Redis instance and a prefix will merge their metric families and produce genuinely baffling output. Prefix per app + environment: acme_prod_.
Instrumentation must never break a request. A Redis hiccup during inc() throws. There's no built-in "fail open" flag. Wrap updates in try/catch (report, don't rethrow) and keep timeout at ~0.1s. Consider a circuit breaker if Redis is remote.
CollectorRegistry::getDefault() is a global static. It leaks between PHPUnit tests and across Octane requests. Use container binding, and in Octane either disable persistent_connections or rebuild the registry in an Octane::tick/fork hook — a socket inherited across fork() is a classic source of interleaved Redis replies.
Changing labels or help text on a deployed metric is a migration. Old metadata persists in Redis/APC. Adding a label to an existing counter can yield exceptions or dropped samples. When you change a definition, call $registry->wipeStorage() once (or delete the prefix keys) as part of the deploy — and expect a data gap in Grafana.
Stale series never expire. The library does not attach TTLs. A label value that appears once (a typo'd status code, a dev branch route) stays in the exposition forever. Watch count by (__name__) in Prometheus, and be strict about label sources.
Collection cost is not zero. getMetricFamilySamples() enumerates the keyspace. The classic Redis adapter uses KEYS, which blocks Redis on large keyspaces — use RedisNg there, and consider a short cache (1–5s) on the /metrics response if you're scraped every 15s by several Prometheus servers. Note also that collection should be read-only (fixed in 2.13) — so it's safe to run from a read replica, but a scrape still costs real IO.
Don't instrument /metrics itself. Either exclude the route from your middleware or you'll get a self-referential spike in latency and request counts on every scrape.
Secure the endpoint. text/plain route at /metrics leaks route names, table names, and business volumes to anyone who guesses. Use a separate vhost/internal listener, IP allow-listing, or Prometheus basic_auth via middleware — and make sure it's exempt from your global web middleware group, CSRF, session, and auth.
php_info shows up by default. Since 2.1 the registry exposes php_info{version="..."} 1. Don't be surprised by it, and don't try to re-register a metric with that name.
Histogram buckets must be ascending and non-empty. [] silently uses library defaults — a trap if you intended an empty histogram. Histogram::exponentialBuckets(0.005, 2, 12) is a good default for HTTP latency; hardcoded [0.1, 0.5, 1] will put 95% of your requests in +Inf.
Gauges don't accumulate. inc() on a gauge is a delta; use set() for absolute values (queue depth) and setToCurrentTime() for timestamps. Mixing them produces sawtooth graphs nobody can explain.
Label values must be strings — an int status code or a nullable tenant_id will bite you (APCng in particular got stricter in 2.12). Cast explicitly, and pass 'none' rather than null.
No OpenMetrics output. RenderTextFormat::MIME_TYPE is text/plain; version=0.0.4. There's no _created series and no exemplars. Fine for virtually all dashboards; just don't write # EOF-dependent tooling.
Batch jobs behind NAT need the PushGateway companion. Don't try to make a short-lived artisan command scrapeable — install promphp/prometheus_push_gateway_php and push from terminate().
Debugging checklist when a metric doesn't appear: (1) is the /metrics route actually hit the same adapter as the code incrementing it? (2) is the prefix identical on both sides? (3) does the name match [a-zA-Z_:][a-zA-Z0-9_:]* and the label [a-zA-Z_][a-zA-Z0-9_]*? (4) did getOrRegister get a different label set than the call site's array? (5) is a try/catch swallowing the exception you're report()ing into a log nobody reads?
How can I help you explore Laravel packages today?