Your first AI call has no cost guardrail. It doesn't need one — it's one request, you're testing, it's fine.
Then it ships. Retries stack up, a queue worker chews through a backlog overnight, one tenant sends ten times the traffic of everyone else combined, and nobody notices until the provider's invoice arrives at the end of the month. There was no point in the request lifecycle where anything could have said "stop."
laravel-ai-tasks builds that stopping point in, as a first-class part of the dispatch flow — not a report you check after the damage is done.
Two checks, not one
Checking budget only before the call isn't enough, because you don't know the real cost of a call until the provider replies with token usage. A tenant sitting $2 under budget can dispatch a request that costs $5 — a pre-flight-only check would let it through.
So the package checks twice:
| Check | When | Uses |
|---|---|---|
| Pre-flight | Before the provider call | Spend so far this month |
| Post-call | Right after the response | The real cost of this response |
Both throw BudgetExceededException. Both run on every path: AI::send(), AI::stream(), and the queued job.
If the post-call check is the one that fires, the provider was already paid — the run still gets recorded as ok with its real cost, instead of being discarded. Otherwise that spend would disappear from the next month's budget math, and the tenant would look like they had room they'd already used.
Configure limits per tenant
// config/ai-tasks.php
'budgets' => [
'default' => ['monthly_usd' => 100.0],
'tenant-abc' => ['monthly_usd' => 50.0],
],
No entry for a tenant → falls back to default. No default either → no limit. Budgeting is opt-in.
Tenant ID comes from TenantResolver:
class TenantResolver
{
public function id(): string
{
if ($id = request()->header('X-Tenant-Id')) {
return (string) $id;
}
if ($u = auth()->user()) {
return (string) ($u->tenant_id ?? $u->company_id ?? $u->id ?? 'default');
}
return (string) config('ai-tasks.default_tenant', 'default');
}
}
X-Tenant-Id header → authenticated user → config default. Got your own tenancy model? Bind your own resolver, one line in a service provider:
$this->app->singleton(
\Fomvasss\AiTasks\Support\TenantResolver::class,
fn () => new MyTenantResolver()
);
A realistic case: triaging support tickets
Budgets matter once there's real traffic behind them. A common one: every incoming support ticket runs through three AI calls before a human sees it — summarize, extract keywords for search, classify category. Three task classes, same shape:
class SummarizeTextTask extends AiTask
{
public function __construct(
private readonly string $text,
private readonly int $maxWords = 50,
) {}
public function toPayload(): AiPayload
{
return new AiPayload(
modality: 'text',
messages: [new UserMessage($this->text)],
systemPrompt: "Summarize in at most {$this->maxWords} words. Only the summary.",
options: ['temperature' => 0.2],
);
}
public function postprocess(AiResponse $resp): array|AiResponse
{
return ['ok' => $resp->ok, 'summary' => trim($resp->content ?? ''), 'usage' => $resp->usage];
}
}
ExtractKeywordsTask and ClassifyContentTask follow the same pattern with a schema() closure for structured JSON. Real, unedited output for one actual ticket ("wireless keyboard hasn't arrived, order #48213, need it for a Friday demo…"):
| Task | Result |
|---|---|
SummarizeTextTask |
"Order #48213 for a wireless keyboard hasn't arrived; tracking hasn't updated. Considering a refund for a client demo." — 100 in / 24 out tokens, cost 2.94e-5
|
ExtractKeywordsTask |
["wireless keyboard", "order 48213", "tracking page", "support chat", "refund"] |
ClassifyContentTask |
{"category": "business", "confidence": 0.9} |
Three calls, one ticket, ~$0.0002 total. Trivial alone — but 10,000 tickets a month is roughly $2 just for triage, before human-facing AI replies, before retries, before a model upgrade for better accuracy. That's the slow, invisible cost a budget check exists to catch. And it's not abstract: each of those three calls is its own row in ai_runs, on the same tenant, feeding the exact spend the budget check compares against.
Check spend without writing a query
Every call is already logged in ai_runs with its cost, so this ships as a command:
php artisan ai:budget default
Real output from my dev sandbox ($1 budget set just to make the numbers non-trivial) — this includes the three ticket-triage calls above:
+---------+-------------------------+-------------+-------------+------------------+
| Tenant | Period | Spent (USD) | Limit (USD) | Remaining (USD) |
+---------+-------------------------+-------------+-------------+------------------+
| default | 2026-07-01 → 2026-07-31 | 0.006698 | 1.0000 | 0.9933 |
+---------+-------------------------+-------------+-------------+------------------+
Production numbers will look nothing like this, but the table and the check behind it are unchanged.
Options:
-
--month=YYYY-MM— check a past month -
--from=YYYY-MM-DD --to=YYYY-MM-DD— arbitrary custom range (spend only — a custom range has no single monthly limit to compare against)
If the tenant's monthly budget is exhausted, the command exits non-zero. Drop it in a cron job and you have a budget alert without writing any alerting code.
Why this matters
Budget enforcement usually gets added after the surprising invoice, patched onto code that was never built to be stopped mid-call. Here it's structural: every dispatch mode hits the same two checks, every run is attributed to a tenant, and checking status is one command instead of a query you have to remember exists.
GitHub: fomvasss/laravel-ai-tasks
Packagist: packagist.org/packages/fomvasss/laravel-ai-tasks
Happy to answer questions in the comments. Stars and issues welcome 🙂
Top comments (0)