The first time an AI feature ran up a real bill, I found out from the invoice — not from my own dashboards. An unbounded summarisation endpoint had been chewing through large documents for a fortnight, and nothing in my app could tell me which feature or which user was responsible. The Laravel AI SDK hands you everything you need to avoid that: every response exposes token usage through a usage object. This walks through capturing that usage per request, attributing it to a user and feature, converting tokens to dollars, and aggregating it into a spend dashboard — without adding a millisecond to the user-facing response. If you're just getting started, the complete guide to the Laravel AI SDK covers the setup this article assumes.
Read the usage object off the AI SDK response#
Every agent response from the Laravel AI SDK carries a usage object. Prompt an agent, then read promptTokens, completionTokens, and totalTokens straight off the response — no extra configuration and no provider-specific parsing. These are the numbers your provider bills against, so they are the only source of truth worth logging.
use App\Ai\Agents\SupportAgent;
$response = (new SupportAgent)->prompt($message);
$response->text; // the generated reply
$response->usage->promptTokens; // input tokens
$response->usage->completionTokens; // output tokens
$response->usage->totalTokens; // prompt + completion
The usage shape is normalised across providers, so swapping OpenAI for Anthropic doesn't change how you read it. That consistency matters once you start juggling models for provider fallback and failover — the counts land in the same place regardless of who served the request.
One edge case to bank now: when you stream a response, usage arrives on the final chunk, not mid-stream. Read it in the completion callback rather than trying to sum deltas yourself.
Create the ai_usages table and model#
You want one row per AI call, carrying enough dimensions to answer "what are we spending, and where?" later. That means the user, the feature that triggered the call, the provider and model, the three token counts, and the computed cost. Generate the migration and model together:
php artisan make:model AiUsage -m
Define the table so every column you'll group by is indexed:
public function up(): void
{
Schema::create('ai_usages', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('feature')->index(); // e.g. "support-agent"
$table->string('provider'); // e.g. "openai"
$table->string('model')->index(); // e.g. "gpt-4o-mini"
$table->unsignedInteger('prompt_tokens');
$table->unsignedInteger('completion_tokens');
$table->unsignedInteger('total_tokens');
$table->decimal('cost_usd', 12, 6); // sub-cent precision
$table->timestamps();
});
}
cost_usd is a decimal(12, 6) on purpose. A single small request can cost a tiny fraction of a cent, and if you round to two places you'll log a pile of $0.00 rows that sum to nothing. Store six decimal places and format for humans only at the dashboard.
class AiUsage extends Model
{
protected $fillable = [
'user_id', 'feature', 'provider', 'model',
'prompt_tokens', 'completion_tokens', 'total_tokens', 'cost_usd',
];
protected function casts(): array
{
return ['cost_usd' => 'decimal:6'];
}
}
Record tokens in a queued job per user, feature, and model#
The write to ai_usages must never sit in the request's critical path. Push it onto the queue: the user gets their AI response immediately, and a worker records the usage a moment later. A queued job also gives you retries for free if the database hiccups.
use App\Models\AiUsage;
use App\Support\AiCostCalculator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RecordAiUsage implements ShouldQueue
{
use Queueable;
public function __construct(
public int $promptTokens,
public int $completionTokens,
public string $provider,
public string $model,
public string $feature,
public ?int $userId,
) {}
public function handle(AiCostCalculator $calculator): void
{
AiUsage::create([
'user_id' => $this->userId,
'feature' => $this->feature,
'provider' => $this->provider,
'model' => $this->model,
'prompt_tokens' => $this->promptTokens,
'completion_tokens' => $this->completionTokens,
'total_tokens' => $this->promptTokens + $this->completionTokens,
'cost_usd' => $calculator->costFor(
$this->model, $this->promptTokens, $this->completionTokens,
),
]);
}
}
Dispatch it right after you have the response, passing the model from config rather than trusting an untyped field on the response:
$response = (new SupportAgent)->prompt($message);
RecordAiUsage::dispatch(
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: config('ai.default'),
model: config('ai.default_model'),
feature: 'support-agent',
userId: $request->user()?->id,
);
return $response->text;
If you'd rather keep this out of your controllers entirely, listen for the SDK's AgentPrompted event and dispatch the same job from a single listener — every agent call gets tracked with zero per-call wiring. Either way, resolve the user and feature at dispatch time; once you're on the worker, the request is gone. This is exactly the problem request-scoped logging with Laravel Context solves if you want the feature and user to ride along automatically. And if usage logging is one link in a larger pipeline, it composes cleanly with queue chains and batches.
No queue worker in this environment? Swap dispatch for dispatchAfterResponse() and the write still happens after the HTTP response is flushed to the client.
Convert tokens to cost with a per-model price map#
Tokens only become useful when they're money. Keep a per-model price map in config, with separate input and output rates, because providers charge more for the tokens they generate than the tokens you send. Rates are quoted per million tokens, so the maths is a division and two multiplications.
// config/ai-pricing.php
return [
// USD per 1M tokens — verify against your provider's current pricing.
'models' => [
'gpt-4o' => ['input' => 2.50, 'output' => 10.00],
'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.60],
'claude-sonnet'=> ['input' => 3.00, 'output' => 15.00],
],
'daily_user_cap_usd' => 5.00,
];
The calculator is a handful of lines. Return zero for an unmapped model, but log it loudly — an untracked model is exactly how spend slips through unnoticed.
namespace App\Support;
use Illuminate\Support\Facades\Log;
final class AiCostCalculator
{
public function costFor(string $model, int $promptTokens, int $completionTokens): float
{
$rates = config("ai-pricing.models.{$model}");
if ($rates === null) {
Log::warning('No price map entry for AI model', ['model' => $model]);
return 0.0;
}
return $promptTokens / 1_000_000 * $rates['input']
+ $completionTokens / 1_000_000 * $rates['output'];
}
}
Two gotchas worth pricing in. Cached input tokens (some providers expose them as promptTokensDetails) are billed at a discount, so a naive input rate slightly overestimates cost on cache hits — fine as a safety margin, worth splitting out if you lean on prompt caching. And reasoning models bill their hidden "thinking" tokens as output, which is why your completion counts can dwarf the visible reply.
Query spend by feature for a cost dashboard#
With cost on every row, the dashboard is one aggregate query away. Group the last thirty days by feature and you immediately see which endpoint is the expensive one — the question the surprise invoice couldn't answer.
use App\Models\AiUsage;
use Illuminate\Support\Facades\DB;
$spend = AiUsage::query()
->where('created_at', '>=', now()->subDays(30))
->select('feature')
->selectRaw('SUM(cost_usd) AS total_cost')
->selectRaw('SUM(total_tokens) AS tokens')
->selectRaw('COUNT(*) AS runs')
->groupBy('feature')
->orderByDesc('total_cost')
->get();
Swap feature for user_id, model, or a DATE(created_at) expression and the same shape answers "who is our heaviest user?" or "what did we spend each day?". This is deliberately a plain table you own, not a bolted-on platform. If you outgrow it and want traces, latency percentiles, and prompt-level drill-down, that's the moment to reach for full LLM observability with Langfuse rather than extending these queries forever.
Cap spend with a pre-flight budget check#
Tracking tells you what happened; a cap stops it happening again. Before an expensive call, sum what the user has already spent today and refuse the request once they cross the line. It's a cheap indexed query against data you're already writing.
$spentToday = AiUsage::where('user_id', $user->id)
->whereDate('created_at', today())
->sum('cost_usd');
abort_if(
$spentToday >= config('ai-pricing.daily_user_cap_usd'),
429,
'Daily AI budget reached. Try again tomorrow.',
);
Keep the cap a soft guardrail, not your only defence — it's a floor check, so a single very large call can still overshoot before the next request is blocked. For per-user throttling that also smooths bursts, pair it with rate-limiting via job middleware on the jobs that make the calls.
Wrapping Up#
You now read usage off every response, record it asynchronously with cost attached, and can answer "what are we spending, and where?" from a single query — plus a cap that stops a runaway feature cold. Roll your own when you want the data in your own tables; reach for a package like laraveljutsu/usaige when you'd rather have a dashboard and helpers out of the box (note it needs PHP 8.5+).
Next, wire your usage assertions into CI so a regression can't silently double your token count — faking agents in your Pest tests lets you assert on usage with zero API spend. And if you serve multiple models, revisit provider fallback and failover so a failover to a pricier model still lands in the same cost table.
FAQ#
How do I get token usage from a Laravel AI SDK response?
Read it off the usage object on the response. After $response = (new SupportAgent)->prompt($message), you have $response->usage->promptTokens, $response->usage->completionTokens, and $response->usage->totalTokens. The shape is normalised across providers, so the same code works whether the call went to OpenAI or Anthropic. When streaming, read usage in the completion callback because it only arrives on the final chunk.
How do I calculate the cost of an AI request in Laravel?
Keep a per-model price map with separate input and output rates, since providers bill generated tokens higher than prompt tokens. Divide each token count by one million, multiply by the matching rate, and add the two figures. Store the result in a decimal column with six places so sub-cent costs don't round away to zero. A small AiCostCalculator service that reads the rates from config/ai-pricing.php keeps the logic in one testable place.
How can I track AI spend per user or feature?
Write one row per call to an ai_usages table with user_id, feature, provider, model, the token counts, and the computed cost. Resolve the user and a feature label at dispatch time and pass them into the queued job that records the row. Then a GROUP BY feature or GROUP BY user_id query over the last thirty days tells you exactly where the money went.
Does logging token usage slow down AI responses?
Not if you log it off the request path. Dispatch a queued job after you have the response so the user gets their reply immediately and a worker writes the row a moment later. If you have no queue worker running, dispatchAfterResponse() defers the write until after the HTTP response is flushed, which still keeps it out of the user's perceived latency.
Can I set a spending cap on Laravel AI SDK calls?
Yes, with a pre-flight check. Before an expensive call, sum the user's cost_usd for today and abort with a 429 once they cross a configured cap. Treat it as a soft guardrail rather than a hard limit — because it checks before the call, one oversized request can still overshoot slightly, so pair it with job-level rate limiting for bursty workloads.