Laravel’s rate limiting is good. You define a limiter, attach the throttle middleware, and the framework handles the counting and the 429 responses. What it does not give you is any answer to the question you actually have when a limit starts firing in production: who is hitting it, and why.
I ran into this directly. An API endpoint started throwing 429 Too Many Requests for a handful of users. The framework was doing its job — but the only evidence I had was the error responses themselves. No record of which IP or token was responsible. No view of which endpoint was under pressure. No way to adjust the limit without editing code and deploying. I was debugging a throttling problem with no visibility into the throttling.
The transport has the data; the framework throws it away
Laravel’s RateLimiter keeps counters in the cache. Those counters are exactly the data you want — request counts per key, per window — but they are ephemeral and per-key. There is no aggregation, no history, no offender ranking, and nothing you can look at. The moment the window decays, the evidence is gone.
So when an incident happens, you are reconstructing it from access logs after the fact, if you log enough to reconstruct it at all. The information existed in memory at the moment it mattered and was never persisted.
This is the same gap that observability tools fill for queries and requests. Rate limiting deserves the same treatment: capture, persist, aggregate, surface.
What “seeing” rate limits requires
I wanted four things:
Capture without rearchitecting. I should be able to instrument a route by swapping its middleware, not by replacing how throttling works. The framework’s RateLimiter stays the source of truth; the package observes it.
Persistence in infrastructure I already run. No new datastore to operate. Metrics should land in the application’s own SQL database through Eloquent, so they survive cache flushes and restarts.
A view, secured by default. A page that shows total versus throttled requests, utilisation, hourly volume, recent events, and — the important one — top offenders grouped by IP, user, or token. And because that page exposes who is hitting your API, it must sit behind authorization, not be reachable by anyone who finds the URL.
Controls, not just charts. When I see an abusive token, I want to tighten its limit without a deploy. When a trusted partner needs headroom, I want to grant an override the same way.
How it works
You install the package, publish its config and migrations, and instrument the routes you care about with the package middleware in place of Laravel’s throttle:
Route::middleware(\Sa\RateLimitDashboard\Http\Middleware\RateLimitInstrumenter::class.':api')
->get('/api/search', SearchController::class);
The middleware records the request, the resolved limiter, and the outcome, then defers to Laravel’s normal throttling. Routes you do not instrument are untouched — there is no global hook on every request, only on the routes you opt in.
Events persist to the host database. Raw events, runtime limiter configuration, audit entries, and rolled-up minute/hour/day summaries all live there. The summaries are what keep the dashboard fast: you query pre-aggregated rows, not millions of raw events.
The dashboard renders behind a gate you control:
Gate::define('viewRateLimitDashboard', fn ($user) => $user->isAdmin());
Default route is /admin/rate-limits. If you leave the dashboard unprotected, a built-in health check flags it — exposing offender IPs and tokens without authorization is a vulnerability, so the package treats protection as a checked requirement, not a suggestion.
Operating it
Two pieces make it usable beyond the first week.
Health checks cover storage, dashboard protection, utilisation, decay settings, offenders, and unconfigured routes. They turn “is rate limiting set up correctly?” into a concrete list instead of something only the person who configured it knows.
Scheduled commands keep it healthy. rate-limit:check-alerts emails you when a configured threshold is breached, so abuse surfaces proactively instead of waiting for a complaint. rate-limit:prune trims old raw events so the events table does not grow without bound — aggregated summaries are kept, raw rows are disposable.
The trade-off I made
The deliberate decision is persisting to the primary SQL database. For most applications that is the right call: zero new infrastructure, metrics that live where everything else lives. The cost is that a very high-traffic API writes events on a path close to the request. For those cases the right extension is an optional buffered or queued ingestion mode and a pluggable storage driver — route events to a time-series store while keeping the same dashboard and checks. That is the main thing I would build next.
What the package buys today is the thing I was missing during that incident: rate limiting you can look at. When a limit fires, you open a page and see who, what, and how much — and you can change the limit from the same page without shipping a deploy.
The package is open source at github.com/satheez/laravel-rate-limit-dashboard.