AI Cost in Laravel: Track Spend per Task and Cache the Prompt That Repeats
An AI bill rarely grows because the model got expensive. It grows because the same 8,000-token prompt rides along on all 20,000 calls that month, and nobody notices because almost nobody stores the usage block the API returns.
Why the Bill Grows When Nobody Touched the Code
Input vs. Output: Which One Actually Weighs
Output tokens cost three to five times more than input tokens per unit, and intuition stops there. But input repeats: an 8,000-token system prompt sent 20,000 times is 160 million input tokens a month, and volume, not unit price, is what builds the invoice.
The Fixed Prompt Resent 20,000 Times
Instructions, examples, rubric, glossary, company policy. All of it identical on every call, all of it billed as fresh input every time. Prompt caching exists precisely for this, and it only works when the prefix is identical byte for byte.
What Isn't Measured Can't Be Fixed
When the bill comes in high, the reasonable question is which task ate it. Without per-call records that question has no answer: you are left estimating by characters and turning the thermostat down blind. That is the order of this tutorial: counter first, price second, cache third, and the brake last. The other way out, the one where you stop paying per token, is covered in local AI with Ollama; here we deal with what happens when you do pay.
Read also
Storing Usage for Every Call
The ai_calls Table and the Column Almost Everyone Skips: task
Without the task column the record tells you how much you spent, not where. It is the room label on the meter.
Schema::create('ai_calls', function (Blueprint $table) {
$table->id();
$table->string('task', 64); // summary, classification, sales-agent
$table->string('provider', 32); // openai, anthropic, gemini
$table->string('model', 64);
$table->unsignedBigInteger('input_tokens')->default(0);
$table->unsignedBigInteger('cached_tokens')->default(0);
$table->unsignedBigInteger('cache_write_tokens')->default(0);
$table->unsignedBigInteger('output_tokens')->default(0);
$table->decimal('cost_usd', 10, 6)->default(0);
$table->unsignedInteger('latency_ms')->default(0);
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
$table->index(['task', 'created_at']);
$table->index('created_at');
});Field Names Differ Across Providers
Everyone returns the breakdown in their own vocabulary. Normalising in one place beats repeating the same conditional in ten controllers. OpenAI uses usage.prompt_tokens_details.cached_tokens (as input_tokens_details in the Responses API). Anthropic returns cache_read_input_tokens and cache_creation_input_tokens. Gemini puts cached_content_token_count in usage_metadata.
class AiUsage
{
public static function fromOpenAi(array $usage): array
{
return [
'input_tokens' => $usage['prompt_tokens'] ?? $usage['input_tokens'] ?? 0,
'cached_tokens' => $usage['prompt_tokens_details']['cached_tokens']
?? $usage['input_tokens_details']['cached_tokens'] ?? 0,
'cache_write_tokens' => 0,
'output_tokens' => $usage['completion_tokens'] ?? $usage['output_tokens'] ?? 0,
];
}
public static function fromAnthropic(array $usage): array
{
$write = $usage['cache_creation_input_tokens'] ?? 0;
$read = $usage['cache_read_input_tokens'] ?? 0;
return [
'input_tokens' => ($usage['input_tokens'] ?? 0) + $write + $read,
'cached_tokens' => $read,
'cache_write_tokens' => $write,
'output_tokens' => $usage['output_tokens'] ?? 0,
];
}
public static function fromGemini(array $meta): array
{
return [
'input_tokens' => $meta['promptTokenCount'] ?? 0,
'cached_tokens' => $meta['cachedContentTokenCount'] ?? 0,
'cache_write_tokens' => 0,
'output_tokens' => $meta['candidatesTokenCount'] ?? 0,
];
}
}The Classic Mistake: Miscounting Cached Tokens on Anthropic
On Anthropic, cache_read_input_tokens and cache_creation_input_tokens do not replace input_tokens: they add to it. Treat them as a substitute breakdown and total input comes out short, and the report lies with complete confidence. That is why normalised input_tokens is the sum of all three fields.
A Service Class That Records Instead of Guessing
Recording happens at the end of the call, with measured latency and the user when there is one. No character-based estimates: the usage the API returns is the data.
class AiRecorder
{
public function store(string $task, string $provider, string $model, array $usage, int $latencyMs, ?int $userId = null): void
{
$cost = AiPricing::cost($provider.':'.$model, $usage);
DB::table('ai_calls')->insert($usage + [
'task' => $task,
'provider' => $provider,
'model' => $model,
'latency_ms' => $latencyMs,
'cost_usd' => $cost,
'user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
]);
}
}Turning Tokens Into Money Without Lying
The Versioned Price Table: Append, Don't Edit
Prices change several times a year. Hard-code them in a controller and every change turns history into fiction: January's costs get recomputed at June's price. The table lives in config/ai_pricing.php with an effective date, and when a provider moves the price you append a row.
return [
// EXAMPLE values with a date. Before using them in production,
// copy the current numbers from the provider's pricing page.
'openai:example-model' => [
'effective_from' => '2026-06-01',
'input' => 0.40, 'cached_input' => 0.10, 'cache_write' => 0.0, 'output' => 1.60,
],
'anthropic:example-model' => [
'effective_from' => '2026-06-01',
'input' => 3.00, 'cached_input' => 0.30, 'cache_write' => 3.75, 'output' => 15.00,
],
];The per-Call Cost Formula, Step by Step
Fresh input is whatever did not come from cache and was not written into it. That leaves one line per concept:
class AiPricing
{
public static function cost(string $key, array $u): float
{
$p = config('ai_pricing.'.$key);
if (! $p) {
return 0.0; // unknown price: store 0 and log a warning
}
$freshIn = max(0, $u['input_tokens'] - $u['cached_tokens'] - $u['cache_write_tokens']);
$usd = $freshIn * $p['input']
+ $u['cached_tokens'] * $p['cached_input']
+ $u['cache_write_tokens'] * $p['cache_write']
+ $u['output_tokens'] * $p['output'];
return round($usd / 1_000_000, 6);
}
}Why Prices Don't Belong in a Controller
Because a controller is not where you audit an invoice. With the table in config, changing a price is a reviewable commit and history stays intact; with numbers scattered through the code, the first tariff change means search-and-replace across five files.
The Command That Answers "Which Task Ate It"
Total Cost, Average Cost and p95 Latency
protected $signature = 'ai:costes {--desde=} {--hasta=} {--tarea=}';
public function handle(): int
{
$desde = $this->option('desde') ? Carbon::parse($this->option('desde')) : now()->startOfMonth();
$hasta = $this->option('hasta') ? Carbon::parse($this->option('hasta'))->endOfDay() : now();
$rows = DB::table('ai_calls')
->selectRaw('task, COUNT(*) as calls, SUM(input_tokens) as input,
SUM(cached_tokens) as cached, SUM(output_tokens) as output,
SUM(cost_usd) as cost, AVG(cost_usd) as avg_cost')
->whereBetween('created_at', [$desde, $hasta])
->when($this->option('tarea'), fn ($q, $t) => $q->where('task', $t))
->groupBy('task')
->orderByDesc('cost')
->get();
$this->table(
['Task', 'Calls', 'Input', 'Cached', 'Output', 'Cost USD', 'Avg USD', 'Hit rate', 'p95 ms'],
$rows->map(fn ($r) => [
$r->task,
number_format($r->calls),
number_format($r->input),
number_format($r->cached),
number_format($r->output),
number_format($r->cost, 2),
number_format($r->avg_cost, 4),
$r->input > 0 ? number_format($r->cached / $r->input * 100, 1).'%' : '0%',
$this->p95($r->task, $desde, $hasta),
])->all()
);
return self::SUCCESS;
}
private function p95(string $task, Carbon $desde, Carbon $hasta): int
{
$base = DB::table('ai_calls')->where('task', $task)->whereBetween('created_at', [$desde, $hasta]);
$total = $base->count();
if ($total === 0) {
return 0;
}
return (int) $base->orderBy('latency_ms')
->skip(min((int) floor($total * 0.95), $total - 1))
->value('latency_ms');
}The Missing Denominator: Cost per Document, Order or Conversation
A 400-dollar total says nothing about whether the business scales or sinks. Divided by 2,000 documents it is 0.20 per document; divided by 40 orders it is 10 dollars each, and that probably does not add up. Add a unit_id column or count units per task in a second query: cost without a denominator is a number without meaning.
Reading the Report Without Fooling Yourself With Averages
The average hides the tail. A task with 200 one-cent calls and 3 two-dollar calls has a calm average and a brutal tail; p95 and the maximum give it away. And watch the cost_usd = 0 rows: they almost always mean a model missing from the price table, not a free call.
How Each Provider Caches
OpenAI: Automatic, With a Token Threshold
Nothing to configure: the provider detects the identical prefix in the request and discounts cached reads to roughly a tenth of the input price, with a minimum on the order of 1,024 tokens. Writes are not billed separately. If the prefix is short, caching simply never engages.
Anthropic: Explicit Breakpoints and Two TTLs
Here you mark up to four breakpoints with cache_control. Default TTL is 5 minutes, with a 1-hour option. Reads run around 0.1x, and writes are billed above the normal input price: about 1.25x for the 5-minute window according to currently circulating figures; for the 1-hour window sources disagree, so read the provider's current table before hard-coding a multiplier.
Gemini: A Higher Threshold and Explicit Caches
Gemini's implicit cache requires a much larger prefix than OpenAI's (on the order of 4,096 tokens), and there is an explicit cache built with cachedContents that you create ahead of time with its own lifetime. The read field is cached_content_token_count inside usage_metadata.
Reordering the Prompt So the Cache Hits
Stable First, Variable Last
One rule, no exceptions: the prefix must be identical byte for byte. Instructions, examples and reference documents first; the user's question and the order data last. Same model, same tools, same JSON key order.
// Anthropic: two stable blocks marked, the variable one last
$payload = [
'model' => 'example-model',
'max_tokens' => 900,
'system' => [
['type' => 'text', 'text' => $instructions, 'cache_control' => ['type' => 'ephemeral']],
['type' => 'text', 'text' => $examples, 'cache_control' => ['type' => 'ephemeral']],
['type' => 'text', 'text' => $document], // changes on every call
],
'messages' => [['role' => 'user', 'content' => $question]],
];The Five Things That Break the Prefix Silently
A date or time at the top of the prompt. The user's name or id inside the fixed block. The order id. A json_encode with keys in a different order each time. And a random result or an "updated at" timestamp recalculated on every call. Any one of the five drops hit_rate to zero and leaves the bill unchanged.
Measure Hit Rate Before and After With 100 Real Tasks
hit_rate = cached_tokens / input_tokens. If it is zero on OpenAI, either the prefix misses the minimum or something inside it changes. If it is zero on Anthropic, the breakpoint is misplaced or the TTL expired between calls. Measure with a hundred real tasks, not one test call: the test is usually the only one with a clean prompt.
A Spend Cap That Stops Before the Call
Budget per Task and per User
The limit is stored per task and per user over a monthly window, and checked right before sending. If part of the spend runs on the subscription with its included API credits, as we covered in ChatGPT's API credits, leave those calls out of the sum or you will be counting twice.
class AiBudget
{
public function guard(string $task, ?int $userId, float $limitUsd): void
{
$spent = DB::table('ai_calls')
->where('task', $task)
->when($userId, fn ($q) => $q->where('user_id', $userId))
->whereBetween('created_at', [now()->startOfMonth(), now()])
->sum('cost_usd');
if ($spent >= $limitUsd) {
throw new AiBudgetExceeded($task, (float) $spent, $limitUsd);
}
}
}Three Answers When It Runs Out: Downgrade, Serve Cache or Say So
Downgrade to a cheaper model (measured beforehand with an evaluation, not assumed). Serve an answer already stored by the app with Cache::remember, which skips the call entirely. Or return an honest message. A limit checked after the invoice arrives is not a limit: it is a lament.
Retries, Idempotency and Calls You Pay For Twice
A retry with backoff on a call that did arrive and was merely slow gets paid twice. The fix is an idempotency key per task (a hash of the parameters) plus an "in progress" record before sending: if the key exists and is in progress, do not resend. And heavy AI calls belong on a queue, like any slow job: we covered that in queues and jobs in Laravel 13.
The Seven Mistakes That Inflate the Bill
Counting tokens with a tokenizer library instead of reading the real usage. Estimating by characters. Hard-coding prices in the controller. Caching the prompt of a task that changes on every call. Confusing the provider's prompt cache with the app's own Cache::remember. Deleting old records "so the table doesn't grow", when they are the only audit of the invoice. And checking the budget after the money is spent.
Conclusion
Record usage together with the task it belongs to, price it with a versioned table and order the prompt so the fixed part gets cached: those three pieces turn the bill from a surprise into a decision. Add a limit that stops before the call and spend becomes a variable you control instead of an accident. We keep publishing this kind of tutorial, with code that runs in production, every week on this blog.


