bilfeldt/laravel-route-statistics
Log Laravel requests and responses for route usage statistics, grouped by route with optional hourly, daily, or monthly aggregation to keep database storage minimal. Track per-user and unauthenticated usage.
The package logs each request and stores it (optionally aggregated) in a single route_statistics table, so you get "who hit which route how many times" without an analytics stack.
Install — requires Laravel 10–13 / PHP 8.2+ for the current v4 line:
composer require bilfeldt/laravel-route-statistics
Publish and run the migration (this is the step people forget — the service provider registers the middleware alias and request macros, but not the schema):
php artisan vendor:publish --provider="Bilfeldt\LaravelRouteStatistics\LaravelRouteStatisticsServiceProvider" --tag="migrations"
php artisan migrate
Publish the config so you can tune aggregation, exclusions and the user model used by the user() relation:
php artisan vendor:publish --provider="Bilfeldt\LaravelRouteStatistics\LaravelRouteStatisticsServiceProvider" --tag="config"
Enable logging — start scoped, not global. In bootstrap/app.php the provider already registers the routestatistics alias, so your first use case should be the authenticated groups you actually care about:
Route::middleware(['auth', 'routestatistics'])->group(function () {
// dashboard, reports, API resources...
});
Reserve the global approach (prepending RouteStatisticsMiddleware in withMiddleware()) for when you genuinely want site-wide coverage — see the gotchas below.
Look here first:
user_id, team_id, method, route, parameters, status, ip, date, counter.RouteStatistic::user().First real use case — hit a few pages, then:
php artisan route:stats # traffic per logged route
php artisan route:unused # routes your app declares but never logs
If route:stats is empty, logging isn't enabled on those routes; if route:unused lists everything, the middleware isn't applied broadly enough.
Three enablement styles, use all three together.
Global (prepend RouteStatisticsMiddleware in withMiddleware()) for full coverage and a trustworthy route:unused report.
Route group (Route::middleware(['routestatistics'])) for the common case where you only care about authenticated app/API traffic.
Ad-hoc macro ($request->routeStatistics() inside a controller) for conditional logging — e.g. log only successful, non-cached, non-export requests:
public function show(Request $request, Report $report)
{
$request->routeStatistics();
return view('reports.show', compact('report'));
}
Enrich with context for per-tenant reporting. The default logger captures user_id and a team_id, but the real leverage is pushing your own dimensions (tenant, plan, API client, feature flag) into the context so you can slice usage later. Do it in a small middleware that runs after tenant resolution and before the response completes:
$request->routeStatistics(); // tag
// then attach context, e.g. team_id / custom columns
This is the pattern that turns the table from "raw request log" into a product-analytics table: groupBy('team_id')->sum('counter') becomes "which customers actually use feature X".
Always aggregate with SUM(counter), never COUNT(*). Aggregation collapses N requests into one row with counter = N. Every dashboard, report and export must be written as:
RouteStatistic::query()
->whereWeb() // or whereApi()
->where('date', '>=', now()->subDays(30))
->where('status', '<', 400) // ignore error traffic when measuring adoption
->groupBy('route', 'user_id')
->selectRaw('route, user_id, SUM(counter) as hits')
->orderByDesc('hits')
->get();
The README hints at the intended dashboard: use the user() relation to render "top users", and group by route for "top routes". Both are one query away once you internalise sum(counter).
Retention is a scheduled job, not a feature. The whole point of aggregation is that the table stays small. Wire a nightly purge so old raw/aggregated rows are dropped:
RouteStatistic::where('date', '<', now()->subMonths(6))->delete();
Pair it with the aggregation config so recent data is granular (minute/hour) and old data is coarse (day/month). If you skip this, you've built a write-amplified request log.
Queue the writes for hot paths. Enable queued logging in the config when the middleware sits on high-traffic API routes — a synchronous insert per request is a real latency cost. Make sure a worker is running in every environment, and confirm the job isn't silently dropped on deploy.
Use route:unused as a maintenance signal, not a hard truth. Schedule it weekly (or run it in CI against a staging environment with production-like traffic) to find endpoints that can be deleted or that lack coverage, before refactoring controllers.
Testing. The package ships a RouteStatisticFactory, so feature tests can assert logging behaviour directly:
$this->get('/dashboard')->assertOk();
$this->assertDatabaseHas('route_statistics', ['route' => 'dashboard', 'status' => 200]);
And set the enabled flag to false in phpunit.xml so unrelated tests don't write rows.
route:stats and route:unused won't reflect real traffic.counter is the number, not the row count. Any query using count() will under-report once aggregation kicks in. This is the single most common reporting bug./posts/{post}), but renaming a path silently starts a new group and orphans historical rows. Name your routes.user_id + ip is personal data. Decide on a retention window, document it, and consider not storing the IP at all if you only need usage counts. Anonymous traffic is exactly what the package is good at surfacing, so don't just drop those rows — hash or aggregate them.status < 400 for usage reporting, and use the error rows deliberately for "who is hitting 403s".date is rounded (microseconds zeroed) according to the aggregation level, and date is cast to Carbon. Don't build exact-timestamp forensics on aggregated rows — keep raw logging on for the window where you need precision.Queue::fake() and nothing in the DB, and a dead worker means a silent data gap. Assert on the job in tests and monitor queue depth.route:unused produces false positives by design. It only knows routes that would be logged. If the middleware covers just your auth group, every public route shows up as "unused". Either accept that, or apply the middleware globally when auditing.ROUTE_STATISTICS_ENABLED=false in .env/phpunit.xml unless you're actively debugging logging — otherwise every local pageview and every test run pollutes your dev database.user_agent/bot flag through the logger context (or exclude those paths) if your numbers look inflated.Bilfeldt\RequestLogger\Contracts\RequestLoggerInterface from bilfeldt/laravel-request-logger — bind your own implementation to change what gets written, and extend RouteStatistic (or point the config's user model at your own) for custom relations and accessors. Check the underlying request-logger README when you need context/grouping APIs beyond what the route-statistics docs show.How can I help you explore Laravel packages today?