laravel-metrics maintained by kevariable
Laravel Metrics
Count what your app does and see it in Grafana, Datadog, CloudWatch, or anywhere else, with one small API.
use Kevariable\Metrics\Facades\Metrics;
Metrics::increment('orders.placed', ['country' => 'id']);
Metrics::timing('checkout.duration_ms', 412.5);
Metrics::gauge('cart.items', 3);
$invoice = Metrics::time('invoice.render_ms', fn () => $pdf->render());
Pick where the numbers go in config, your code never changes. Built on
beberlei/metrics v3, the same library behind the
Symfony BeberleiMetricsBundle.
- One API, many backends: Prometheus, StatsD, DogStatsD, OpenTelemetry, CloudWatch, InfluxDB, Graphite, Telegraf, a log channel, or several at once
- Named collectors like cache stores:
Metrics::collector('statsd')->increment(...) - Flushed for you after the response is sent, after every queue job, after every console command, and after every Octane request, task and tick
- Prometheus done right: shared storage for PHP-FPM, correct labels, timings as real
histograms (p95 ready), and an optional
/metricsscrape endpoint Metrics::fake()with readable assertions for your tests
Installation
Requires PHP 8.4+ and Laravel 12 or 13.
composer require kevariable/laravel-metrics
php artisan vendor:publish --tag=metrics-config
Then choose a collector in .env:
METRICS_COLLECTOR=prometheus
The default is null, so nothing is sent until you opt in.
Usage
use Kevariable\Metrics\Facades\Metrics;
Metrics::increment('orders.processed');
Metrics::decrement('stock.available', ['sku' => 'A-42']);
Metrics::measure('import.rows', 1500);
Metrics::timing('payment.gateway_ms', $ms);
Metrics::gauge('queue.depth', 120);
$result = Metrics::time('report.build_ms', fn () => $builder->build());
Calls chain, and every method accepts tags as the last argument:
Metrics::increment('orders', ['status' => 'ok'])
->timing('orders.duration_ms', $ms, ['status' => 'ok']);
Prefer injection or a helper over a facade? Both give you full autocompletion:
public function __construct(private MetricsManager $metrics) {}
metrics()->increment('orders');
Named collectors
config/metrics.php works like config/cache.php. Each entry in collectors is a named
collector with a driver:
Metrics::collector('statsd')->increment('orders');
Metrics::collector('stack')->increment('orders');
| Driver | Sends to | Options |
|---|---|---|
prometheus |
promphp registry, exposed for scraping | namespace, storage (redis, apcu, in_memory), redis.connection, redis.database, buckets, tags |
statsd, dogstatsd, telegraf |
UDP agent | host, port, prefix |
graphite |
Graphite | host, port, protocol |
log |
a Laravel log channel | channel |
chain |
several named collectors at once | collectors |
opentelemetry |
an OpenTelemetry meter provider | meter_provider (container id) |
cloudwatch |
AWS CloudWatch | client (container id), namespace |
influxdb_v1, influxdb_v2 |
InfluxDB | database / write_api (container id) |
in_memory, null |
nowhere, handy for local dev |
Container ids are resolved for you, so bind your client once and point the config at it:
'otel' => [
'driver' => 'opentelemetry',
'meter_provider' => \OpenTelemetry\API\Metrics\MeterProviderInterface::class,
],
Custom drivers
Metrics::extend('pulse', fn ($app, array $config) => new PulseCollector($config));
Any class implementing Beberlei\Metrics\Collector\CollectorInterface works. Implement
GaugeableCollectorInterface too if it supports gauges.
Prometheus
Prometheus pulls, so metrics have to live somewhere every PHP worker can reach. PHP-FPM forgets everything between requests, so the package stores them in Redis by default, using your existing Laravel Redis connection:
'prometheus' => [
'driver' => 'prometheus',
'namespace' => 'app',
'storage' => 'redis',
'redis' => ['connection' => 'default', 'database' => 2],
'buckets' => [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
],
timing()records a histogram (in milliseconds, with the buckets above), so you get_bucket,_sumand_countand can graph averages and percentiles:histogram_quantile(0.95, sum by (le) (rate(app_checkout_duration_ms_bucket[5m])))- Dots and dashes in names become underscores:
orders.placedisapp_orders_placed - Prometheus needs the same tag keys on every call for a metric. Sending
orderswith['status']and later with['country']is reported through your exception handler instead of silently mixing them up
The scrape endpoint
Turn it on and point Prometheus at /metrics:
METRICS_PROMETHEUS_ROUTE=true
'prometheus' => [
'route' => [
'enabled' => env('METRICS_PROMETHEUS_ROUTE', false),
'path' => 'metrics',
'collector' => 'prometheus',
'middleware' => ['throttle:60,1'],
'domain' => null,
],
],
Metrics are often sensitive, so protect it with middleware (an IP allowlist, basic auth, or keep it on an internal domain).
Need values that only make sense at scrape time, like OPcache stats or queue depth? Add
them with onScrape. They are computed on each scrape and never stored:
Metrics::onScrape(function (CollectorRegistry $registry) {
$registry->getOrRegisterGauge('php', 'opcache_hit_ratio', 'OPcache hit ratio')
->set(opcache_get_status(false)['opcache_statistics']['opcache_hit_rate'] / 100);
});
Serving it yourself? Metrics::renderPrometheus() returns the text.
PHP-FPM tip: the scrape is a PHP request like any other. When your pool is saturated, it waits behind user traffic and your graphs go blank right when you need them. Give
/metricsits own small FPM pool.
When metrics are sent
Calls are buffered in memory and written in one go, so they cost almost nothing during the request. The package flushes:
| When | Hook |
|---|---|
| after the HTTP response is sent | terminating |
| after every queue job, including failed attempts | JobProcessed, JobExceptionOccurred |
| after every artisan command | CommandFinished |
| after every Octane request, task and tick | RequestTerminated, TaskTerminated, TickTerminated |
Each hook can be switched off in config/metrics.php under flush. Long running code of
your own can call Metrics::flush() whenever it likes.
Testing
use Kevariable\Metrics\Facades\Metrics;
public function test_orders_are_counted(): void
{
$metrics = Metrics::fake();
$this->post('/orders', [...]);
$metrics->assertIncremented('orders.placed')
->assertIncremented('orders.placed', ['country' => 'id'], times: 1)
->assertTimed('checkout.duration_ms')
->assertGauged('cart.items', 3)
->assertNotSent('orders.failed');
}
| Assertion | |
|---|---|
assertIncremented($name, $tags = [], $times = null) |
tags are matched as a subset |
assertDecremented($name, $tags = [], $times = null) |
|
assertTimed($name, $tags = [], $times = null) |
|
assertGauged($name, $value = null, $tags = []) |
|
assertMeasured($name, $value = null, $tags = []) |
|
assertSentTo($collector, $name) |
which named collector received it |
assertNotSent($name) / assertNothingSent() |
|
recorded($type = null, $name = null) |
a collection of everything sent |
Why not the upstream Prometheus collector?
beberlei/metrics v3.0.0's Prometheus collector registers tag values as label names
(['status' => 'failed'] becomes {failed="failed"}) and stores timings as a gauge. This
package ships its own Prometheus collector with correct labels and histogram timings.
Every other backend is the upstream collector, unchanged.
Credits
- beberlei/metrics by Benjamin Eberlei, maintained by Grégoire Pineau and JoliCode
- promphp/prometheus_client_php
- Kevin Abrar Khansa
License
MIT. See LICENSE.