Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Prometheus Client Php Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install

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).

2. Pick the storage adapter first — this is the real design decision

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.

3. Wire it in once

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.

4. Expose /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']

5. First use case: request counter middleware

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.


Implementation Patterns

Provider: one binding, adapter-agnostic

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(),
        };
    }
}

Don't call getOrRegister* in hot loops

getOrRegisterCounter() 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]);
    }
}

Metric design rules that matter in Laravel

  • Use route names, never raw paths. $request->route()->getName() (users.show) keeps cardinality at O(routes). $request->path() (users/8341) is an outage waiting to happen.
  • Base units + suffixes. *_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.
  • Namespace = app/bounded context. getOrRegisterCounter('billing', 'invoices_total', ...) renders as billing_invoices_total. Use '' for a global.
  • Prefer Histograms over Summaries. Summary quantiles are computed client-side per process, so with N workers the 0.99 you see is an average of samples, not a real tail. Histograms aggregate correctly in Prometheus (histogram_quantile).

Queue / Horizon / scheduler instrumentation

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.

Testing: swap in InMemory

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');

Keep metrics code out of controllers

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.


Gotchas and Tips

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?

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator