AI usage tracking for Laravel. Every AI call your app makes through the
Laravel AI SDK is recorded automatically — provider,
model, tokens, the prompt that was asked, what the call cost you, what you
billed for it, and the margin between the two. An admin dashboard at /usage
shows the whole ledger per call and per user.
Install it and tracking starts. No code changes required.
- PHP 8.3+
- Laravel 12 or 13
laravel/ai^0.9ext-bcmath(money math never touches floats)
composer require devdojo/usage
php artisan migrateThat's it — the package's event listeners register themselves and every agent prompt, stream, embedding, image, and transcription made through the AI SDK is recorded from the next call onward.
Publish the config when you want to change defaults:
php artisan vendor:publish --tag=usage-configTables are prefixed usage_ (usage_events, usage_meters, usage_rollups).
Change table_prefix in the config before migrating if that collides with
anything.
Tracking must never break the thing it measures. Every capture path is wrapped in a guard with three layers:
- The listener extracts a plain payload and hands it to a queued job, so recording never blocks or delays the AI response.
- If the queue is unreachable, the event is written inline instead of lost.
- If that write fails too, the failure is logged and swallowed.
An exception inside tracking is never re-thrown into your application. If the
usage_events table is missing, the queue is down, and the database is on
fire, your AI call still returns normally. This behavior is covered by tests
(tests/Feature/FailOpenTest.php).
Queued writes use your default connection unless you point them elsewhere:
'queue' => [
'connection' => env('USAGE_QUEUE_CONNECTION'),
'queue' => env('USAGE_QUEUE'),
],By default the package stores the user's prompt in full, a 140-character preview for list views, a SHA-256 hash, and the response capped at 2,000 characters — enough to look at any event and know exactly what was being built.
Prompts can contain personal data, so all of it is yours to dial down:
'store_prompts' => true, // true, 'hash', or false
'prompt_max_length' => null, // null stores prompts in full
'prompt_preview_length' => 140,
'store_responses' => true,
'response_max_length' => 2000,'hash'keeps only the SHA-256 hash — identical prompts stay groupable, but nothing readable is stored.falsestores nothing about the prompt at all, not even the hash.- Anything that looks like a credential (OpenAI/Anthropic keys, GitHub and
Slack tokens, AWS keys, bearer tokens,
api_key=...assignments) is always stripped before storage. This is not configurable on purpose.
The AI SDK reports token counts but not cost, so cost comes from the pricing
map in config/usage.php — rates per 1 million tokens, as strings, keyed
by provider and model. Patterns support a trailing * and match top to
bottom, so specific models go before broad families:
'pricing' => [
'anthropic' => [
'claude-sonnet-4*' => ['input' => '3.00', 'output' => '15.00', 'cache_read' => '0.30', 'cache_write' => '3.75'],
],
'openai' => [
'dall-e-3' => ['per_unit' => '0.04'], // priced per image, not per token
],
],Worth knowing:
- A model missing from the map records a
nullcost, not zero. Unknown is not free — the dashboard shows a dash and you know what to add. - Cached tokens are never double-billed. Providers disagree about whether
cached tokens are included in
prompt_tokens(OpenAI-style APIs) or reported separately (Anthropic, Gemini). The package normalizes this at capture, so the pricing math is uniform. - Shipped rates were correct when written, but provider prices drift — verify against your providers' pricing pages and override freely.
- Cost, price, and the exact rate card used are snapshotted onto each event. Repricing the config later never rewrites history.
Tell the package what you bill and margin appears everywhere:
'prices' => [
'per_million_tokens' => '30.00',
],For finer control, set default_unit_price on a meter row — a meter's price
beats the global one. No price configured means events record cost only and
the dashboard simply shows no revenue.
Each event is attributed to a polymorphic billable. Resolution order:
- A billable forced by the
TrackUsagemiddleware (below) - A
Usage::attributeTo($user, fn () => ...)scope around the call - The agent's conversation participant (
RemembersConversationsagents) - Your custom fallback:
Usage::resolveBillableUsing(fn () => ...) - The authenticated user
No match still records the event — as system usage with no billable.
Basic tracking needs nothing. For agents where you want more, add the middleware — never global, always opt-in:
use Devdojo\Usage\Middleware\TrackUsage;
class BuilderAgent implements Agent, HasMiddleware
{
public function middleware(): array
{
return [new TrackUsage(meter: 'builder', metadata: ['feature' => 'builder'])];
}
}It can pin events to a meter, attach metadata, force a billable, and store the original prompt as written before other middleware revised it (put it first in the array for the truest original).
For AI calls made outside the SDK — a custom proxy, a raw HTTP call:
use Devdojo\Usage\Facades\Usage;
Usage::record([
'provider' => 'anthropic',
'model' => 'claude-sonnet-4-5',
'input_tokens' => 1200,
'output_tokens' => 480,
'billable' => $user,
'metadata' => ['source' => 'chat-proxy'],
]);Manual records run through the same pricing and land in the same tables. Pass
total_cost yourself and it is kept and marked cost_source: manual.
Dashboard reads for closed days come from pre-aggregated usage_rollups, so
they never scan raw events. Schedule the command:
Schedule::command('usage:rollup')->hourly();It recomputes each closed day in a trailing window (default 3 days) and
upserts, so it is idempotent and absorbs events that arrived late through the
queue. Today is always read live from raw events. Use --days=30 or
--since=2026-01-01 to reach further back.
/usage shows the call ledger (user, model, tokens, cost, prompt preview,
time) and a Users view — one card per person with avatar, name, email, and
their cost for the timeframe (All Time / 30 Days / Week / Day, defaulting to
30 days), plus billed amount and margin when you price usage.
It denies everyone by default. The package registers the viewUsage gate
as deny-all; opening it up is an explicit act in your app:
Gate::define('viewUsage', fn (User $user) => $user->isAdmin());Path, middleware, and the gate name are configurable:
'dashboard' => [
'enabled' => true,
'path' => 'usage',
'middleware' => ['web', 'auth'],
],
'gate' => 'viewUsage',The dashboard ships its own styles — no Tailwind build step, no asset publishing, works in any app, light and dark.
- Events are immutable. No
updated_at, and the model throws if you try to update one.recorded_atis when the usage happened;created_atis when the row was written. - Idempotent. The SDK's invocation id doubles as an idempotency key, so a retried queue job can never double-record a call.
- Meters are referenced by slug, never by foreign key — deleting a meter leaves history intact. Unknown slugs create their meter row on first use.
- Turn everything off with
USAGE_ENABLED=false. Existing data stays put. - Prepaid credits, balance grants, and invoicing are deliberately out of scope for v1 — events carry everything (immutable, snapshotted, idempotent) a credits layer needs to sit on top later.
composer testThe suite covers the fail-open guarantee end to end, cost and margin math, token normalization, privacy modes, idempotency, rollups, and dashboard authorization.