stechstudio/laravel-metrics
A Laravel package for recording and querying application metrics. Track counters, trends, and key performance or business data over time through a simple API, backed by database storage, so you can monitor and visualize what matters in your app.
Install and publish the config
composer require stechstudio/laravel-metrics
php artisan vendor:publish --tag="metrics-config"
The package is auto-discovered. Everything you need to configure lives in config/metrics.php — read this file end-to-end before writing any code. It is the map: it declares which driver is active, the credentials for each sink, and (critically) which Laravel events are auto-tracked.
Pick a driver in .env
METRICS_DRIVER=influxdb # influxdb | prometheus | cloudwatch | posthog
INFLUX_URL=https://influx.example.com
INFLUX_TOKEN=...
INFLUX_ORG=acme
INFLUX_BUCKET=app-metrics
Swap the block for AWS_* credentials (CloudWatch), a PostHog API key/host, or nothing at all for Prometheus (which is scrape-based, see the gotchas).
First use case: prove the pipe end-to-end
Pick one business event and one performance number. Don't start with the middleware or the auto-tracking — emit a single manual metric and confirm it lands in the sink, because each sink has a very different feedback loop:
use STS\Metrics\Facades\Metrics;
// in a listener for your domain event
Metrics::increment('signups', tags: ['plan' => $user->plan]);
Then verify against the sink, not the code: InfluxDB Data Explorer, the CloudWatch console under your namespace, the PostHog activity feed, or curl localhost:8000/metrics for Prometheus. Only once you've seen the datapoint does it make sense to wire up more events.
Where to look first
config/metrics.php — driver + event mappings.README.md — the driver table (each sink needs materially different config).src/Drivers/ — when the docs are thin, the driver class is the source of truth for what options actually work.Emit from listeners, not controllers. The package's value is that metrics are a side effect of events you already dispatch. Register a listener for OrderPlaced, UserRegistered, ReportGenerated and put the Metrics:: call there. Controllers stay thin, and you get metrics for free from queued jobs, scheduled commands, and webhooks that never touch HTTP.
Use the auto-tracking config for the boring stuff. config/metrics.php lets you map framework events (Login, JobProcessed, JobFailed, NotificationSent) to metric names without writing listeners. This is the highest-leverage five minutes you'll spend — queue failure counts and login counts are the two dashboards you'll actually look at.
Time the things that hurt. Wrap slow operations in a measurement rather than hand-rolling hrtime():
Metrics::measure('reports.generate', fn () => $builder->generate(), tags: ['type' => $type]);
Pair every duration metric with a counter on failure, otherwise a "p95 dropped" chart can just mean "everything started erroring fast."
Separate dev from prod. In local, either set the driver to a log/null driver or gate emission behind a config flag. Nothing is worse than a teammate's artisan tinker session polluting the production Influx bucket with test.metric series.
Keep tags low-cardinality, always. Tags/dimensions are the group-by keys of your future dashboards. Good: plan, queue, status_code, region, driver. Bad: user_id, order_id, url, trace_id. In CloudWatch every unique tag combination is a separately billed metric; in Prometheus it's a separate time series in memory forever.
Namespace your metric names. Adopt domain.entity.action (orders.created, auth.login.failed) with the unit baked in for gauges/timings (queue.depth, reports.generate_ms). You will otherwise end up with signup, Signups, user_signup, and new_user all meaning the same thing.
Fake it in tests. Use the facade's swap/fake so feature tests can assert a metric was recorded instead of hitting a real sink — the same pattern as Bus::fake() and Mail::fake(). Assert on names and tags, not on payload shape.
Treat sinks as best-effort. A metrics backend being down must never 500 a checkout. Confirm the driver swallows/ logs transport errors (check the driver class for a "fail silently" or "throw" toggle) and, if it doesn't, wrap your emission points in a small helper of your own.
Prometheus + PHP is the big one. Prometheus is pull-based and assumes a long-lived process; PHP-FPM workers are per-request. A naive in-memory counter resets to zero on every request, so your histogram_quantile and rate() queries return garbage. Options: back the registry with Redis/APCu so all workers share state, run a pushgateway for batch/queue metrics, or scrape per-instance and aggregate in PromQL. Decide this before you build dashboards on it.
The /metrics endpoint needs protecting. If the package registers a scrape route, it will happily expose your application's internals to the internet. Restrict it at the web server (IP allowlist) or with middleware — not with an auth guard Prometheus can't satisfy.
Long-running queue workers may not flush. Some drivers batch writes and flush on request termination. queue:work doesn't terminate for hours, so metrics can sit in a buffer or be lost entirely. Check the driver's flush behavior and look for an explicit flush() you can call from a JobProcessed listener or the worker's --stop-when-empty boundary.
CloudWatch dimensions are not tags. They're indexed, billed per unique combination, and you can only aggregate across a dimension set you've actually published. Adding a dimension later means back-filling is impossible — the historical data isn't there.
PostHog is product analytics, not a time-series DB. Counts are approximate, retention windows apply, and you'll query it with HogQL rather than PromQL/Flux. Use it for funnels and cohorts; don't try to run SLO alerting off it.
Config caching bites. With php artisan config:cache, env() calls outside config files return null. Always route credentials through config('metrics...') and re-run config:cache after changing .env.
Verify driver option keys against the published config. This package moves fast and the README can lag the driver implementation. After publishing, cross-check config/metrics.php against src/Drivers/* before debugging why a credential "isn't picked up" — it's usually a renamed key.
Extension points: a custom driver (implement the package's driver contract and register it in the config's driver map) and custom event→metric mappings are the two seams you'll actually use. Everything else is intentionally thin.
Debug locally with the log driver. When a metric doesn't appear, switch to the log driver in local and confirm the metric is being emitted before blaming the sink. Debugging in that order saves an hour of credential archaeology.
How can I help you explore Laravel packages today?