systemverk / laravel-api-usage
Self-hosted, actor-aware API usage analytics for Laravel. Track who uses your API, which endpoints they use, how much they use them, and how they perform.
Requires
- php: ^8.2
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/redis: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5|^12.0
- predis/predis: ^2.2|^3.0
Suggests
- ext-redis: Recommended Redis client for production throughput (alternative: predis/predis).
- predis/predis: Pure PHP Redis client, usable when ext-redis is unavailable.
Provides
None
Conflicts
None
Replaces
None
README
Self-hosted, actor-aware API usage analytics for Laravel.
Track who uses your API, which endpoints they use, how much they use them, and how they perform — without an external observability platform.
ApiUsage::usage() ->thisMonth() ->forActor(UsageActor::organization(42)) ->summary();
$summary->totalRequests; // 18420 $summary->serverErrors; // 3 $summary->averageDurationMs; // 46.7 $summary->errorRate(); // 0.0121
What problem does this solve?
Most Laravel applications can tell you that their API is busy. Far fewer can answer "how much did this customer use us last month", "which endpoint is getting slower", or "which API key is generating all the 422s" — the questions that show up in support threads, capacity planning and invoicing.
This package answers them from your own database. Every request is buffered in Redis after the response has been sent, flushed to SQL in batches by a scheduled command, and rolled up into per-actor, per-endpoint daily and monthly summaries you query through a small PHP API.
When should I use this?
| You need | Reach for |
|---|---|
| Distributed tracing across services | OpenTelemetry |
| Debugging individual requests in development | Telescope, a request logger |
| Error tracking and alerting | Sentry, Bugsnag |
| API usage analytics per customer, tenant or API key | This package |
These are different jobs, not competitors. This package deliberately does not ship a dashboard, enforce quotas, trace across services, or record request bodies. See Out of scope.
Requirements
| Supported | |
|---|---|
| PHP | 8.2, 8.3, 8.4, 8.5 |
| Laravel | 12.x, 13.x |
| Redis client | ext-redis (recommended) or predis/predis, for the default redis driver |
| Database | MySQL 8, MariaDB 11, PostgreSQL 17 — the full suite runs against all three in CI. SQLite works for local development and tests, but serializes writes. SQL Server is not tested |
Quick Start
1. Install
composer require systemverk/laravel-api-usage
2. Run migrations
php artisan migrate
The package ships its migrations and loads them automatically — there is nothing to publish. Two tables are created:
api_usage_requests— one row per recorded requestapi_usage_summaries— daily and monthly aggregates
3. Choose a driver
The driver setting decides how a finished request is stored. Both run after
the response has been sent.
| Driver | What it does | Use it when |
|---|---|---|
redis (default) |
Appends the request to a Redis list; api-usage:flush moves it into SQL in batches |
The application has real traffic |
database |
Inserts the request into SQL immediately. No Redis, no flush step | Small applications, or you cannot run Redis |
The database driver costs one INSERT per request on your database, with its
two indexes to maintain. For a small application that is negligible next to
what the request itself already did; at sustained volume it becomes constant
write load, and the Redis driver's batched inserts are far cheaper. If you use
the database driver, point API_USAGE_DB_CONNECTION at a dedicated connection
so usage writes never compete with the application's own. Switch with
API_USAGE_DRIVER=database; no other change is needed, and the flush command is
then no longer scheduled.
Redis driver
- A connection must exist under
database.redis. Redis Cluster is not supported: the flush command renames a buffer to a key in another hash slot, which a cluster refuses - The package uses the
defaultconnection unless told otherwise - Override with
API_USAGE_REDIS_CONNECTION
If the configured connection does not exist, the middleware records nothing rather than throwing — usage tracking never takes an application down.
4. Run the scheduler
The commands are registered on the scheduler automatically, but they only run if your scheduler runs.
* * * * * php /path/to/app/artisan schedule:run >> /dev/null 2>&1
5. Check that it is working
php artisan api-usage:status
Architecture
flowchart LR
A[HTTP request] --> B[RecordApiUsage]
B --> C[Application]
C --> D[HTTP response sent]
D --> E[terminate: resolve actor + endpoint]
E --> F[(Redis buffer)]
F -->|api-usage:flush, every minute| G[(api_usage_requests)]
G -->|api-usage:consolidate-daily| H[(api_usage_summaries)]
H --> I[ApiUsage::usage / endpoints / actors]
Loading
The important property is where the line falls: nothing between the request
arriving and the response leaving touches SQL. Actor resolution, endpoint
resolution, serialization and the Redis write all happen in terminate(), after
the client already has its response.
Failure is handled the same way at every step. The first request of each minute
registers that minute's buffer in a Redis set, so a flush finds every buffer
however long it was away. A minute buffer is claimed with
RENAMENX into a private processing key, tracked in a Redis set, and only
deleted once the database write is confirmed — so a crashed or failed flush is
retried on the next run instead of silently losing entries. See
What can be lost.
Terminology
Actor
An entity usage is attributed to. The package never assumes it is a User:
user:42 · organization:12 · tenant:acme · api_key:abc123 ·
service_account:billing · guest
An actor has a type and an id, stored as two columns. UsageActor::key()
joins them as type:id (or guest) when you want one value to group or compare
on, but that key is never stored. Ids are normalized to strings, so integer and
UUID keys behave identically.
Credential
Which key the actor used — a personal access token, an API key, an OAuth client. This is a second dimension, orthogonal to the actor: it lets you keep "the Acme team" as the actor while still breaking usage down per token.
Endpoint
The logical Laravel endpoint, not the concrete URL. The endpoint key prefers
GET:api.orders.show over GET:/api/orders/123, so resource ids never
fragment your analytics. The raw path is still stored on each row for debugging.
Actor Tracking
Out of the box, usage is attributed to the authenticated user, and to guest
when there is none. That is one class:
use Systemverk\LaravelApiUsage\Actors\AuthenticatedUserActorResolver;
Because Sanctum lets any model be tokenable, this already gives you team- or
organization-level attribution when your tokens belong to a Team.
Custom actor resolver
Anything else is a resolver of your own:
use Illuminate\Http\Request; use Systemverk\LaravelApiUsage\Actors\UsageActor; use Systemverk\LaravelApiUsage\Contracts\ResolvesUsageActor; final class OrganizationActorResolver implements ResolvesUsageActor { public function resolve(Request $request): ?UsageActor { $organizationId = $request->user()?->organization_id; if (! $organizationId) { return UsageActor::guest(); } return UsageActor::make('organization', $organizationId); } }
// config/api_usage.php 'actor' => [ 'resolver' => OrganizationActorResolver::class, ],
Resolvers are built through the container, so constructor injection works.
Returning null means do not record this request at all — that is how
track_guests drops anonymous traffic.
Per-credential attribution
credential_id is filled by a callback you register. The package never guesses:
// app/Providers/AppServiceProvider.php use Illuminate\Http\Request; use Systemverk\LaravelApiUsage\Support\UsageRecorder; public function boot(): void { UsageRecorder::resolveCredentialUsing( fn (Request $request) => $request->user()?->currentAccessToken()?->getKey() ); }
The same closure can live in actor.credential_resolver in the config file
instead, at the cost of making the config uncacheable. A resolver registered in
code wins.
// Everything one team did this month, across all of its keys ApiUsage::usage()->thisMonth()->forActor(UsageActor::make('team', $team->id))->summary(); // Just one key ApiUsage::usage()->thisMonth()->forCredential($token->id)->summary();
Endpoint Tracking
RouteEndpointResolver is the default and needs no configuration. For unusual
routing setups, implement ResolvesUsageEndpoint and point
endpoint.resolver at it. Unlike actor resolution it never returns null: a
request that matched no route is still recorded.
Such requests only reach the middleware when it is registered globally. By
default they all share the endpoint METHOD:/{unmatched}, so scanners probing
random paths cannot add a summary row per path; the raw rows keep the real
path. Set endpoint.unmatched (API_USAGE_UNMATCHED_ENDPOINTS) to path to key
them by path instead.
Durations are measured from the web server's request start, so they include bootstrap and all middleware, and a request rejected early (a 429 from a throttle, say) still carries a real duration.
Querying
Three entry points, all sharing the same period selection and filters.
Periods
today() · yesterday() · thisWeek() · lastDays(int $days) ·
thisMonth() · lastMonth() · between(DateTimeInterface $from, DateTimeInterface $to)
All dates are UTC and both ends of a range are inclusive. Without a period, a query covers the current month.
Filters
forActor(UsageActor $actor) · forActorType(string $type) ·
forCredential(string|int $id) · forEndpoint(string $endpointKey)
Queries are immutable, so a partially built query is safe to reuse:
$thisMonth = ApiUsage::usage()->thisMonth(); $acme = $thisMonth->forActor(UsageActor::organization(1))->summary(); $globex = $thisMonth->forActor(UsageActor::organization(2))->summary();
Usage totals
ApiUsage::usage()->lastDays(7)->summary(); // UsageSummary ApiUsage::usage()->thisMonth()->count(); // int
UsageSummary exposes totalRequests, informational, successfulRequests,
redirects, clientErrors, throttled (the part of clientErrors that was
status 429), serverErrors, totalDurationMs,
averageDurationMs, minDurationMs, maxDurationMs, plus errorRate(),
serverErrorRate(), hasUsage() and toArray(). An empty period returns zero
counts and null durations — never a misleading zero-millisecond average.
Endpoint analytics
ApiUsage::endpoints()->thisMonth()->mostUsed(); // Collection<EndpointUsage> ApiUsage::endpoints()->thisMonth()->slowest(5); ApiUsage::endpoints()->thisMonth()->mostErrors(); ApiUsage::endpoints()->thisMonth()->all();
Each EndpointUsage carries endpointKey, method, routeName, routeUri
and a full UsageSummary. Ordering ties break on the endpoint key, so results
are stable between runs.
Actor analytics
ApiUsage::actors()->lastDays(30)->mostActive(); ApiUsage::actors()->lastDays(30)->mostErrors(); ApiUsage::actors()->lastDays(30)->forActorType('organization')->mostActive();
Each ActorUsage carries actorType, actorId, actorKey, a UsageSummary,
and actor() to turn the result back into a UsageActor you can feed into
another query. Different actor types never collide: user:42 and
organization:42 are separate rows.
Query freshness
The query API reads daily summaries, not raw rows. Summaries are small, outlive raw retention, and already carry duration totals. The cost is freshness: numbers are as current as the last consolidation run.
By default the package re-consolidates the current day every hour, so
today() is at most an hour behind. Consolidation is idempotent — a rerun
recomputes the day rather than double-counting it — so you can run it as often
as you like:
php artisan api-usage:consolidate-daily --today
Turn the hourly run off with API_USAGE_SCHEDULE_CONSOLIDATE_TODAY=false on
very high-volume installations, and accept that today's usage appears after the
nightly run.
Raw access
Both Eloquent models are public API, and are the right tool for questions the query services do not cover:
use Systemverk\LaravelApiUsage\Models\ApiUsageRequest; use Systemverk\LaravelApiUsage\Models\ApiUsageSummary; // Slowest concrete paths in the last 24 hours ApiUsageRequest::query() ->where('requested_at', '>=', now()->utc()->subDay()) ->selectRaw('path, count(*) as hits, avg(duration_ms) as avg_ms') ->groupBy('path') ->orderByDesc('avg_ms') ->limit(10) ->get(); // Server errors today ApiUsageRequest::query()->statusClass(5)->whereDate('requested_at', today())->count(); // Monthly rollups for one actor ApiUsageSummary::query()->monthly()->forActor(UsageActor::organization(42))->get();
Treat them as the advanced API. The query services are the stable surface; the schema may change in a future major version.
What Runs Automatically
| Command | Frequency | Purpose |
|---|---|---|
api-usage:flush |
every minute | Redis buffer → api_usage_requests |
api-usage:consolidate-daily --today |
hourly | Keeps today's summaries fresh |
api-usage:consolidate-daily --days=2 |
daily at 02:00 | Yesterday's and the day before's raw rows → daily summaries; the earlier day catches events flushed late |
api-usage:consolidate-monthly |
monthly on day 1 at 03:00 | Daily → monthly summaries |
api-usage:prune |
daily at 03:10 | Applies the retention windows |
Plus api-usage:status, which is never scheduled: it reads the enabled state,
Redis and database connectivity, buffer depth, sampling rate and retention
windows, and mutates nothing.
RecordApiUsage is appended to the api middleware group. If that group does
not exist, registration is skipped silently.
Configuration
Defaults are usable as-is. Publish the config only if you need to change them:
php artisan vendor:publish --tag=api-usage-config
| Key | Env | Default | Description |
|---|---|---|---|
enabled |
API_USAGE_ENABLED |
true |
Master on/off switch |
driver |
API_USAGE_DRIVER |
redis |
redis (buffer, then flush) or database (one INSERT per request) |
actor.resolver |
— | AuthenticatedUserActorResolver |
Class deciding who a request belongs to |
actor.track_guests |
API_USAGE_TRACK_GUESTS |
true |
Record unauthenticated traffic |
actor.credential_resolver |
— | null |
Callback resolving the credential used |
endpoint.resolver |
— | RouteEndpointResolver |
Class deciding the endpoint identity |
buffer.connection |
API_USAGE_REDIS_CONNECTION |
default |
Redis connection name |
buffer.key_prefix |
API_USAGE_REDIS_KEY_PREFIX |
api_usage: |
Redis key prefix |
buffer.ttl_seconds |
API_USAGE_REDIS_TTL_SECONDS |
7200 |
Buffer key TTL (min 60) |
buffer.flush_batch_size |
API_USAGE_FLUSH_BATCH_SIZE |
1000 |
Rows per insert statement |
database.connection |
API_USAGE_DB_CONNECTION |
null |
Dedicated connection, or the app default |
database.tables.requests |
API_USAGE_TABLE_REQUESTS |
api_usage_requests |
Raw table name |
database.tables.summaries |
API_USAGE_TABLE_SUMMARIES |
api_usage_summaries |
Aggregate table name |
database.consolidation_chunk_size |
API_USAGE_CONSOLIDATION_CHUNK_SIZE |
2000 |
Read chunk size during the monthly rollup |
sampling.rate |
API_USAGE_SAMPLING_RATE |
1.0 |
Fraction of requests recorded |
except |
— | ['up', 'health'] |
Paths never recorded (supports *) |
privacy.hash_ips |
API_USAGE_HASH_IPS |
true |
Store a salted hash, or nothing at all |
privacy.ip_hash_salt |
API_USAGE_IP_HASH_SALT |
null |
Defaults to app.key |
privacy.record_user_agent |
API_USAGE_RECORD_USER_AGENT |
true |
Store the user agent string |
privacy.request_id_headers |
— | X-Request-Id, X-Correlation-Id |
Correlation headers, in priority order |
retention.raw_days |
API_USAGE_RETENTION_RAW_DAYS |
30 |
Raw row retention |
retention.daily_days |
API_USAGE_RETENTION_DAILY_DAYS |
730 |
Daily summary retention (0 = forever) |
retention.monthly_months |
API_USAGE_RETENTION_MONTHLY_MONTHS |
0 |
Monthly summary retention (0 = forever) |
middleware.auto_register |
API_USAGE_AUTO_MIDDLEWARE |
true |
Auto-append the middleware |
middleware.group |
API_USAGE_MIDDLEWARE_GROUP |
api |
Group to append the middleware to |
schedule.enabled |
API_USAGE_SCHEDULE_ENABLED |
true |
Auto-register scheduled commands |
schedule.consolidate_today |
API_USAGE_SCHEDULE_CONSOLIDATE_TODAY |
true |
Hourly refresh of today |
schedule.daily_at |
API_USAGE_SCHEDULE_DAILY_AT |
02:00 |
Daily consolidation time |
schedule.monthly_at |
API_USAGE_SCHEDULE_MONTHLY_AT |
03:00 |
Monthly consolidation time |
schedule.prune_at |
API_USAGE_SCHEDULE_PRUNE_AT |
03:10 |
Prune time |
Excluding noisy endpoints
'except' => ['up', 'health', 'webhooks/*'],
Sampling
'sampling' => ['rate' => 0.1], // record roughly one request in ten
Counts are never scaled back up. At a rate below 1.0 the package reports what it observed, not an estimate of the true total, because a number labelled "total requests" that is silently extrapolated is worse than an honest sample. Keep the rate at 1.0 if the counts must be exact — for billing, say.
Retention
| Data | Default | Why |
|---|---|---|
| Raw rows | 30 days | Detailed, grows fastest, only needed for recent debugging |
| Daily summaries | 730 days | Small, and what the query API reads |
| Monthly summaries | forever | Tiny, and the basis for year-over-year comparisons |
api-usage:prune applies all three, deletes in chunks so a large backlog never
holds one long transaction open, and is safe to run repeatedly. Set a summary
retention to 0 to keep it indefinitely.
Note that consolidation recomputes a day from raw rows, so re-running
consolidate-daily for a date whose raw rows have been pruned will zero that
day's summaries. Backfill before you prune, not after.
Privacy
What is stored:
- Timestamp, actor type/id, credential id, HTTP method, route name, route URI, concrete path, endpoint key, status code, duration
- A salted SHA-256 digest of the IP address, or nothing when
hash_ipsis off - The user agent string, unless
record_user_agentis off - One correlation id, from the headers you list
What is never stored:
- Request bodies · response bodies · query-string values · cookies ·
Authorizationheaders · any header other than the configured correlation id - Actor labels.
UsageActoraccepts a human-readable label for your own use in query results, and it is deliberately dropped before serialization, so a customer name cannot leak into the usage tables by accident.
Two things to be aware of:
- Request paths are stored verbatim. If your API puts secrets in the URL
path, exclude those routes via
except. - Rotating
APP_KEYchanges the default IP hash salt, so hashes recorded before and after will not correlate. Set an explicitAPI_USAGE_IP_HASH_SALTif that matters.
Reliability
What can be lost
- Redis unreachable when a request finishes. That request is not recorded. The failure is logged at warning level; the response is unaffected.
- Redis loses data (eviction, flush, failover to an empty replica) before a flush. Those buffered events are gone.
- Flushing is down for longer than
buffer.ttl_seconds(two hours by default). Buffers that have not been flushed by then expire in Redis. - The database refuses an event — a constraint violation or a data error.
A failing buffer is retried as it is at first, since most failures are
transient. After three failures the next run inserts it row by row, writes
everything the database accepts and moves each refused event, exactly as it was
buffered, to the
api_usage:rejectedRedis list (newest 1000, kept for seven days).api-usage:statusshows how many are waiting there; inspect them withLRANGE api_usage:rejected 0 -1. Failures that say nothing about the row — a lost connection, a deadlock, a missing table — never cause an event to be rejected; the buffer stays and is retried. - A resolver throws. An actor resolver failure drops that one record rather than misfiling it; an endpoint resolver failure falls back to the raw path and keeps the record.
What can be double-counted
Delivery into api_usage_requests is at-least-once, not exactly-once. A
flush inserts the batch, then deletes the buffer. If the process is killed
between those two steps, the next run replays the batch and those rows are
inserted twice — inflating that day's raw rows and, once consolidated, its
summaries.
The window is milliseconds wide and only opens on a hard kill, not on an ordinary error. It is called out here rather than papered over: if your counts feed invoicing, reconcile against a source that is exactly-once.
What is guaranteed
- A failed database write never discards buffered events. The claimed buffer stays in the processing registry and is retried on the next run.
- Two flushes running concurrently cannot both process the same buffer: it is
claimed atomically with
RENAMENXand guarded by a short-livedSET NXlock. - Consolidation cannot double-count. Rows are upserted on the full aggregation identity — period, actor, credential, endpoint — and each run recomputes the period from scratch.
- A usage-tracking failure never fails an HTTP request. Everything happens after the response is sent, and every path is wrapped.
Manual Wiring
API_USAGE_AUTO_MIDDLEWARE=false API_USAGE_SCHEDULE_ENABLED=false
// bootstrap/app.php use Systemverk\LaravelApiUsage\Http\Middleware\RecordApiUsage; ->withMiddleware(function (Middleware $middleware) { $middleware->api(append: [RecordApiUsage::class]); }) // routes/console.php use Illuminate\Support\Facades\Schedule; // No withoutOverlapping() on flush: its default mutex lasts 24 hours, and // overlapping runs are safe because every buffer is claimed atomically. Schedule::command('api-usage:flush')->everyMinute(); // Give every overlap guard an expiry shorter than the gap between runs: the // default of 24 hours lets a stale lock from a killed run skip the next daily run. Schedule::command('api-usage:consolidate-daily --today')->hourly()->withoutOverlapping(50); Schedule::command('api-usage:consolidate-daily --days=2')->dailyAt('02:00')->withoutOverlapping(720); Schedule::command('api-usage:consolidate-monthly')->monthlyOn(1, '03:00')->withoutOverlapping(720); Schedule::command('api-usage:prune')->dailyAt('03:10')->withoutOverlapping(720);
Data Model
All timestamps are stored in UTC, independent of app.timezone.
api_usage_requests
| Column | Notes |
|---|---|
requested_at |
UTC |
actor_type, actor_id |
Ids are strings; a guest is guest / guest |
credential_id |
Nullable; whatever the credential resolver returned |
| cred:{id}` when a credential is known | |
method, route_name, route_uri, path |
path truncated to 1024 chars |
endpoint_key |
METHOD:route_name, METHOD:route_uri or METHOD:/{unmatched} (METHOD:/path with endpoint.unmatched = path) |
status_code, duration_ms |
|
ip_hash |
Salted SHA-256, or null |
user_agent |
Truncated to 512 chars, or null |
request_id |
First matching correlation header, 64 chars |
There are no created_at/updated_at columns: rows are written once, and
requested_at is the moment that matters.
The table takes an insert per API request, so it is indexed sparingly: on
requested_at (consolidation and pruning) and on
(actor_type, actor_id, requested_at) for "what did this actor do during this
period". Everything else is unindexed on purpose. If you query the raw rows by
request_id, endpoint_key or similar, add that index in a migration of your
own.
api_usage_summaries
One row per (period_type, period_start, actor_type, actor_id, credential_id, endpoint_key) — the full aggregation identity, and the unique index
consolidation upserts on.
period_type is day or month. Alongside the counters
(total_requests, responses_1xx … responses_5xx) each row carries
total_duration_ms, min_duration_ms and max_duration_ms.
responses_429 is not a class of its own: it is the part of responses_4xx that
was status 429, counted apart so that a request turned away by a rate limit can
be told from a 404 or a 422. total_requests still includes it, which makes
total_requests - responses_429 - responses_5xx the number of requests that were
neither throttled nor the application's own failure.
credential_id is NOT NULL here and holds an empty string when no credential
was used, because a nullable column in a unique index would defeat the upsert:
SQL treats every NULL as distinct, so traffic without a credential would
accumulate duplicate rows on every rerun. ApiUsageSummary hides this: the
attribute reads as null and accepts null. If you query the table directly,
filter on '' rather than NULL. The unique index is the only one on this
table; its leading (period_type, period_start) columns serve period scans and
pruning.
What This Package Deliberately Does Not Do
No dashboard. No Filament dependency. No billing or quota enforcement. No rate limiting. No request/response body logging. No distributed tracing or OpenTelemetry exporter. No query profiling or N+1 detection. No p95/p99 latency — that needs a histogram design this package does not have, and a percentile computed from daily min/max would be a lie.
Its whole job is: measure API usage; do not become an observability platform.
Testing
composer install
composer test
composer analyse
The suite runs against SQLite in memory with an in-memory Redis double, so no services are required.
Contributing
See CONTRIBUTING.md. Security issues: SECURITY.md.
License
MIT — see LICENSE