jimdo/prometheus_client_php
PHP Prometheus client library for exposing counters, gauges, and histograms, with Redis, APCu, or in-memory client-side aggregation and text-format rendering. Note: development has moved to endclothing/prometheus_client_php.
1. Install — but not this package. The Jimdo repo is archived and explicitly redirects to the maintained fork. Use that one; the API is a superset and PHP 8 compatible:
composer require promphp/prometheus_client_php
Only pull jimdo/prometheus_client_php if you're pinning an existing legacy app (PHP ≤ 7.4, no PHP 8.1+ deprecation noise).
2. Pick an adapter before writing any metric code. This is the single decision that shapes everything else:
| Adapter | Use when |
|---|---|
Prometheus\Storage\Redis |
Default for web apps. Shared across FPM workers and across hosts. Supports Summary. |
Prometheus\Storage\APCng / APC |
Single-host, APC(u) installed, no Redis. Per-pool only — breaks behind a load balancer. |
Prometheus\Storage\InMemory |
Unit tests and long-running CLI daemons (queue workers, cron loops). Useless in FPM: state dies with the request. |
Run a Redis on the same box/socket as the PHP workers — every metric write is a round-trip, so a cross-AZ Redis will show up in your p99.
3. Register the registry once, in a container binding (Laravel example):
// app/Providers/PrometheusServiceProvider.php
$this->app->singleton(\Prometheus\Storage\Adapter::class, function () {
return new \Prometheus\Storage\Redis([
'host' => config('prometheus.redis.host', '127.0.0.1'),
'port' => config('prometheus.redis.port', 6379),
'timeout' => 0.1,
'read_timeout' => 10,
'persistent_connections' => false,
'prefix' => config('prometheus.redis.prefix', 'PROMETHEUS_'),
]);
});
$this->app->singleton(\Prometheus\CollectorRegistry::class, fn ($app) =>
new \Prometheus\CollectorRegistry($app->make(\Prometheus\Storage\Adapter::class))
);
4. Expose the scrape endpoint. The library does not ship HTTP handling — that's on you:
Route::get('/metrics', function (\Prometheus\CollectorRegistry $registry) {
$renderer = new \Prometheus\RenderTextFormat();
return response(
$renderer->render($registry->getMetricFamilySamples()),
200,
['Content-Type' => \Prometheus\RenderTextFormat::MIME_TYPE] // text/plain; version=0.0.4
);
})->withoutMiddleware(['web', 'auth']); // keep it off the session/CSRF stack
5. First real use case — one counter in middleware. Increment on every request, keyed by route template and status class:
$counter = $registry->getOrRegisterCounter(
'app', 'http_requests', 'HTTP requests', ['route', 'method', 'status']
);
$counter->inc(['users.show', 'GET', '2xx']);
Where to look first: src/Prometheus/CollectorRegistry.php (the getOrRegister* contract), src/Prometheus/Storage/Redis.php (key layout, prefix, Lua scripts), and src/Prometheus/RenderTextFormat.php (what actually hits the wire). Read the MetricFamilySamples output once with curl localhost/metrics | head -50 before you trust any dashboard.
RED metrics via middleware. Wrap the request lifecycle; use the route template ($request->route()->getName() or the compiled URI pattern), never $request->path(), or you'll mint a label value per user ID:
public function handle($request, Closure $next)
{
$timer = $this->histogram->startTimer([$route, $request->method()]);
$response = $next($request);
$timer->observeDuration();
$this->counter->inc([$route, $request->method(), substr((string) $response->getStatusCode(), 0, 1).'xx']);
return $response;
}
Register the histogram with buckets tuned to your SLO, not the defaults (0.005…10.0) — 13 buckets × N label combos is 13N Redis sorted sets:
$registry->getOrRegisterHistogram(
'app', 'http_request_duration_seconds', 'Duration', ['route', 'method'],
[0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
);
Wrap it in a small facade/service, not raw registry calls. You want one place that owns namespaces, label ordering, and metric definitions, so a rename is a one-line change and tests can swap in InMemory:
class Metrics
{
public function __construct(private CollectorRegistry $registry) {}
public function orderPlaced(int $cents, string $channel): void
{
$this->registry->getOrRegisterCounter('app', 'orders_placed', 'Orders placed', ['channel'])
->inc([$channel]);
$this->registry->getOrRegisterHistogram('app', 'order_value_cents', 'Order value', [], [1000, 5000, 10000, 50000])
->observe($cents);
}
}
Call it from domain services/events, not controllers — business metrics belong next to the business logic.
Gauges for "current state" only. Queue depth, cache entries, active connections. Set them from a scheduled job that queries the real source, or from the worker itself. Never use a gauge for anything cumulative — resets and multi-worker overwrites will produce sawtooth garbage. Gauges don't sum across workers: each set() is the last write wins.
Long-running workers and cron. The InMemory adapter is genuinely useful here — a queue worker holds counters in its own process and the values are flushed... which means nothing scrapes them. Two real options:
/metrics from InMemory, one port per worker.$body = (new RenderTextFormat())->render($registry->getMetricFamilySamples());
Http::withBody($body, RenderTextFormat::MIME_TYPE)
->post(config('prometheus.pushgateway').'/metrics/job/'.$jobName);
Remember to DELETE the job key on start, or you'll scrape stale values after a crash.
Testing. Bind InMemory in the test container and assert on rendered output — that's the only assertion surface the library gives you:
$adapter = new \Prometheus\Storage\InMemory();
$registry = new \Prometheus\CollectorRegistry($adapter);
// ... exercise code ...
$text = (new \Prometheus\RenderTextFormat())->render($registry->getMetricFamilySamples());
$this->assertStringContainsString('app_orders_placed{channel="web"} 1', $text);
$adapter->wipeStorage(); // between tests
Naming conventions for dashboards that survive. Namespace = app or subsystem (app, billing, worker). Unit suffix on the metric name (_seconds, _bytes, _total). Keep the cardinality budget written down somewhere: every label value combination costs a Redis key and a series in Prometheus.
This repo is archived. Bug reports and PHP 8 fixes go nowhere. Migrate to endclothing/prometheus_client_php (Packagist promphp/prometheus_client_php) — the CollectorRegistry::getDefault() static is deprecated there in favour of constructor injection, so a container binding written today survives the swap.
CollectorRegistry::getDefault() is global mutable state. Convenient in the README, painful in tests (the registry persists across test methods within a process) and impossible to reconfigure per-environment. Always construct explicitly and bind it as a singleton.
getOrRegister* throws on re-registration with a different signature. MetricsRegistrationException fires if the same name comes back with different label names or bucket bounds. During a rolling deploy where you add a label, old and new workers disagree and requests start 500ing. Sequence it: deploy the new label set under a new metric name, backfill dashboards, then delete the old one. There is no atomic rename.
Label values must be a positional list. They're zipped against the registered label names by index — an associative array's keys are ignored, so ['status' => '200'] silently mislabels. Always pass array_values()-ordered scalars, and make a helper that enforces the order.
Never let unbounded values into labels. User IDs, raw URLs, exception messages, SQL strings, full IPs, session IDs. One bad label can take down the scrape (Prometheus will refuse or the target will OOM on series churn). Cap it: route templates, status classes, error types.
Metric/label name validation is strict. [a-zA-Z_:][a-zA-Z0-9_:]* for names, label names can't start with __, and le / quantile are reserved. Namespacing uses _ as the joiner, so a namespace ending in _ gives you a double underscore. Invalid names throw at registration time, not at scrape time — which is better, but only if the code path actually runs, so test the cold path.
Counter naming and _total. Prometheus convention is foo_total. Depending on version, this library either appends _total for you or exposes the name verbatim — check your actual /metrics output before naming an alert, or you'll write increase(foo_total[5m]) against a series called foo.
Summary metrics are effectively Redis-only. Quantile computation needs time-windowed storage that InMemory and APC don't provide. If you're on APC and need quantiles, either switch adapters or use histograms + histogram_quantile() in PromQL.
APCu and CLI don't mix by default. apc.enable_cli=0 means every artisan command writes to a throwaway cache. Set apc.enable_cli=1 or your cron metrics disappear. Also: APC is per-host, so a 3-node app behind an LB only exposes one third of your traffic to each scrape — either scrape each node separately (honor_labels/target per host) or don't use APC.
Redis adapter specifics. Default key prefix is PROMETHEUS_ — change it if the Redis instance is shared, or wipeStorage() in a test will nuke someone else's keys. Keys look like PROMETHEUS_:counter:app:http_requests. Histogram observes and Summary observes go through Lua EVAL, so a Redis ACL that disallows EVAL breaks them with a confusing error. Keep timeout low (~100ms) — metric writes should never be the reason a request hangs.
Each getOrRegister* call is a storage read. It's cheap but not free; don't call it inside a tight loop. Resolve metrics once (constructor or request-scope cache), then inc()/observe() many times.
The /metrics route is a load-test and an information leak. It's unauthenticated by design (Prometheus can't do OAuth easily), so bind it to a private interface, allowlist the scraper IP, or use a sidecar. Expose it via a dedicated nginx location that bypasses PHP-FPM if the metric set is static. And exclude it from your own RED middleware or you'll double-count scrapes.
Empty output is the most common surprise. A blank /metrics body means the metrics were registered in a different worker (APC), the prefix changed, or getMetricFamilySamples() returned nothing because registration happened on a code path that didn't execute that request. Redis debugging:
redis-cli --scan --pattern 'PROMETHEUS_*' | head
redis-cli type 'PROMETHEUS_:counter:app:http_requests'
redis-cli hgetall 'PROMETHEUS_:counter:app:http_requests'
Validate the exposition format before you page anyone. promtool check metrics < /tmp/metrics.txt catches duplicate series, bad names, and malformed help text — all things the renderer will happily emit.
PHP 8.1+ on the Jimdo release produces deprecation notices from internal strlen(null)-style calls; if warnings get promoted to exceptions by your error handler, metric recording becomes a 500. Another reason to take the fork.
Extension point if you need custom storage: implement Prometheus\Storage\Adapter (write, update, wipeStorage, collect). Useful for a PDO-backed or memcached-backed store, but you own atomicity — the built-in adapters get it from Redis Lua / apcu_inc.
How can I help you explore Laravel packages today?