beberlei/metrics
A lightweight PHP metrics library for instrumenting applications with counters, gauges, and timings. Pluggable collectors send measurements to backends like StatsD and Graphite, keeping monitoring code decoupled from your application logic.
beberlei/metrics v3 is still a framework-agnostic PHP library, not a Laravel package: no service provider, no config file, no auto-discovery, no facade. The v3 Symfony bundle does not help you here — you wire it into Laravel yourself, but v3 makes that easier than v2 because Factory is now the supported construction path.
composer require beberlei/metrics:^3.0
Check your runtime first: v3 requires PHP >= 8.4 and psr/log ^3.0. If you are on 8.2/8.3, you stay on 2.x and none of the release below applies.
Coming from 2.x? Read UPGRADE.md before touching code — Collector was renamed to CollectorInterface, every method is now strictly typed, returns void, and takes array $tags = []. Any custom collector you wrote will fatal until you update its signatures.
Then read the source in this order:
src/.../Collector/CollectorInterface.php — the whole API surface. increment, decrement, timing(int|float $milliseconds), measure, gauge (only on GaugeableCollectorInterface), flush. All accept $tags.src/.../Factory.php — now the intended entry point. It builds every bundled collector (statsd, telegraf, null, prometheus, doctrine_dbal, influxdb_v1, influxdb_v2, cloudwatch, opentelemetry, chain) from a config array.src/.../Collector/ — the individual collectors, especially Chain, OpenTelemetry, and NullCollector.Bind the interface once, driving it from config through the factory:
// app/Providers/MetricsServiceProvider.php
public function register(): void
{
$this->app->scoped(CollectorInterface::class, function ($app) {
return Factory::create($app['config']->get('metrics.collector'));
});
}
Confirm the exact array shape against Factory::create() and the bundle's collectors documentation — it is the same configuration vocabulary. Register the provider manually (bootstrap/providers.php on Laravel 11+, config/app.php on older versions).
First use case: instrument one high-value path. Add a counter to a domain event and a timer around an HTTP request, verify locally with the null collector, then point the factory at statsd (or chain with [statsd, cloudwatch]) in staging. If you already run OpenTelemetry, skip straight to the opentelemetry collector and get histograms and dashboards for free.
Config first, factory second. Keep every driver decision in config/metrics.php and let Factory::create() consume it; never read env() from inside a service class or construct collectors by hand.
// config/metrics.php
return [
'collector' => [
'type' => env('METRICS_COLLECTOR', 'null'),
'prefix' => env('METRICS_PREFIX', 'myapp'),
'host' => env('STATSD_HOST', '127.0.0.1'),
'port' => (int) env('STATSD_PORT', 8125),
// ...collector-specific keys; see Factory and the demo app config
],
];
Inject CollectorInterface, never a concrete class. All collectors and Factory are final in v3, so this is no longer just good taste — extension is only possible by implementing the interface. It also lets you swap StatsD for CloudWatch, InfluxDB, or OTel without touching call sites.
Use Chain instead of duplicating calls. Fan-out to several backends is now a first-class collector: send the same metric to StatsD and OpenTelemetry, or to a local Graphite and CloudWatch. Note that gauge() is only forwarded to collectors implementing GaugeableCollectorInterface — the rest silently drop it.
Scoped bindings replace Symfony's kernel.reset. The bundle tags collectors kernel.reset; the Laravel equivalent is a scoped() binding (or forgetScopedInstances()), which resets the instance per request under Octane. Do not use singleton() for collectors that hold buffers or per-request state.
Request middleware with terminable flush. The library still has no request-lifecycle awareness, so you own flush().
final class RecordRequestMetrics
{
public function __construct(private CollectorInterface $metrics) {}
public function handle($request, Closure $next)
{
$start = microtime(true);
$response = $next($request);
$this->metrics->timing(
'http.request.duration',
(microtime(true) - $start) * 1000, // milliseconds, float is fine in v3
['method' => $request->method(), 'status' => (string) $response->getStatusCode()]
);
return $response;
}
public function terminate($request, $response): void
{
$this->metrics->flush();
}
}
Domain events over controller sprinkles. One listener per meaningful event (OrderPlaced, PaymentFailed, UserRegistered) keeps metric names and tag sets stable and testable, and keeps metrics out of HTTP code.
Queue and scheduler instrumentation. Time the work and flush() in a finally so long-running Horizon/queue workers don't accumulate state. increment('scheduler.ran', 1, ['command' => $event->command]) is usually enough to catch a silently dead cron.
Tags are now native — use them, carefully. Pass low-cardinality tags per call, or fix a base set once in the collector constructor:
$this->metrics->increment('orders.placed', 1, [
'channel' => $order->channel, // small, bounded set
'region' => config('app.region'),
]);
Never tag user IDs, order IDs, or raw URLs. The StatsD/Telegraf dialect encodes tags into the metric name (metric,key=value:value|type), so each unique tag set is a distinct series — tags change the ergonomics, not the cardinality math.
Prefixing via a decorator. There is still no namespace concept beyond the collector's prefix option; if you need dynamic per-tenant prefixes, wrap the collector in a class implementing CollectorInterface that prepends before delegating. Cheap, and it survives upgrades.
Testing. Bind the null collector in the test container, or write a recording fake implementing CollectorInterface (~40 lines with the tag parameter) and assert on the recorded name, value, and tags. With final classes you cannot subclass collectors to spy — the fake is the only route, and it is the better one anyway.
Choose collectors by latency profile. StatsD/Telegraf are UDP fire-and-forget. CloudWatch (PutMetricData), InfluxDB v2, and DoctrineDBAL are synchronous network/DB calls. Put those behind Chain only if you understand the added latency, and prefer emitting them from queued jobs rather than the request path.
Metrics::increment() ergonomics, add your own facade over the CollectorInterface binding; constructor injection remains the better default.beberlei/metrics:^3.0 will not install on 8.3.psr/log ^3.0 is a dependency) for collector failures, and verify each backend once in staging with the shipped examples/ Docker + Grafana stack. Silent data loss is the default failure mode of every metrics pipeline.Collector → CollectorInterface, GaugeableCollector → GaugeableCollectorInterface, InfluxDB → InfluxDbV1 (factory type influxdb_v1), removal of TaggableCollector/setTags(), Null, InlineTaggableGaugeable*, Zabbix, and Librato. measure()/increment()/decrement() map to an OpenTelemetry UpDownCounter, timing() to a Histogram, and gauge() to a Gauge — so measure() is not a gauge in OTel-land.timing() to int|float milliseconds with fractional values preserved by every collector. The old "units are backend-dependent" trap is gone; still suffix or document the unit for humans reading dashboards.gauge() lives on a separate interface. Anything typed against CollectorInterface cannot call it, and Chain drops it for non-gaugeable members. If you need gauges, type-hint GaugeableCollectorInterface or keep a concrete reference.kernel.reset tagging has no Laravel analogue unless you use scoped() bindings. Under Octane or Horizon, flush per request/job and never hold per-request state on the collector.created now stores a full datetime (Y-m-d H:i:s) and measurement must accept floating-point values — migrate the table before enabling it, or flush() will fail (and, per the notes, a rollback failure no longer masks the original exception, which is an improvement but still noisy).Factory is final with reworded error messages. Do not match on exception text in tests or tooling; assert on your own config validation instead.^3.0 and re-read UPGRADE.md before a 4.x. The API is small and stable, but v3 proved this project is willing to make sweeping, hard-breaking changes at a cadence that is not Laravel-package-friendly.CollectorInterface (strict types, void returns, $tags) is the intended escape hatch. A decorator adding tenant prefixes, a measureTiming(callable $fn) helper, or a debug-mode recording collector is the fastest way to gain ergonomics without forking.How can I help you explore Laravel packages today?