Looking to hire Laravel developers? Try LaraJobs
This package is not available.

laravel-metrics maintained by kevariable

Description
Send app metrics from Laravel to Prometheus, StatsD, OpenTelemetry, CloudWatch and more, with one API. A Laravel bridge for beberlei/metrics.
Last update
2026/09/26 14:56 (dev-main)
License
Links
Downloads
6

Comments
comments powered by Disqus

Laravel Metrics

Latest Version Tests Total Downloads License

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 /metrics scrape 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, _sum and _count and 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.placed is app_orders_placed
  • Prometheus needs the same tag keys on every call for a metric. Sending orders with ['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 /metrics its 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

License

MIT. See LICENSE.