Provider-side spend limits protect you from one thing: a catastrophic bill. They do nothing about the actual problem in a SaaS product, which is that five percent of your users consume eighty percent of your tokens while paying the same subscription as everyone else. The fix has to live in your application, because only your application knows which tenant a request belongs to. Here is the consumption-control setup I build into client products, with the Laravel version of the code.
Why provider-side limits are not enough
Your API key has one budget at the provider, shared by every customer. When it runs out, everyone is cut off โ your best-paying customers included โ because one free-tier user found a way to hammer your AI feature. App-side metering inverts that: every tenant has a budget, heavy users hit their ceiling, and everyone else never notices. It also gives you the per-tenant cost data you will eventually need for pricing decisions, which no provider dashboard can offer.
Step 1: meter every call
Every LLM response includes actual token usage โ record it, do not estimate it. I keep a single append-only table (tenant_id, feature, model, input_tokens, output_tokens, cost_micros, created_at) plus a fast counter in Redis for the current period, so quota checks never touch the ledger table.
The recording itself belongs in one place, not sprinkled through feature code. A middleware on your AI routes handles the quota gate, and the expensive bookkeeping runs after the response has been sent using Laravel's terminate() hook:
class EnforceTokenBudget
{
public function handle(Request $request, Closure $next): Response
{
$tenant = $request->user()->currentTeam;
$budget = TokenBudget::for($tenant); // cached per-plan limits
if ($budget->hardExceeded()) {
return response()->json([
'error' => 'ai_quota_exceeded',
'renews_at' => $budget->renewsAt(),
], 429);
}
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
// Usage attached by the AI service after the provider call
if ($usage = $request->attributes->get('llm_usage')) {
RecordTokenUsage::dispatch(
$request->user()->currentTeam->id,
$usage['feature'],
$usage['input_tokens'],
$usage['output_tokens'],
);
}
}
}
The queued RecordTokenUsage job increments the Redis counter and inserts the ledger row. Nothing in the request path waits on a database write, and if the queue hiccups you lose a little accounting precision, never a user request.
Step 2: soft and hard quotas
One limit is a blunt instrument; two limits give you a customer conversation instead of a support ticket. The pattern I use:
- Soft quota (typically 80% of plan allowance). Nothing is blocked. The user sees an unobtrusive "you've used most of this month's AI allowance" notice, and the account owner gets one email. This converts surprisingly well to plan upgrades โ it is a pricing signal, not a punishment.
- Hard quota (100โ120%). The middleware above returns a clean, machine-readable 429 with the renewal date. Set it above 100% for paying plans: a customer mid-task on the last day of the month should finish their task. The overage is pennies; the goodwill is not.
Both thresholds live in the plan configuration, not in code โ you will tune them, and marketing will want to change them more often than you deploy.
Step 3: budget alerts for you, not just the user
A scheduled command runs hourly and compares each tenant's month-to-date spend against their trailing average. Two alerts matter: a tenant at 3ร their normal daily burn (either abuse, a runaway integration, or a genuinely thrilled customer โ all three are worth knowing about the same day), and aggregate spend across all tenants against your monthly provider budget. The same numbers feed a small internal dashboard: cost per tenant, cost per feature, margin per plan. Laravel's rate limiter also earns its place here as a first line of defence โ a per-minute cap per user catches scripted abuse long before the monthly budget does.
Step 4: degrade gracefully at the cap
What happens at the hard limit is a product decision, and "the button stops working" is the worst available option. In rough order of preference:
- Route to a cheaper model. Over-quota requests get the small model instead of the flagship. The feature still works; the marginal cost drops by 80โ90%. For many features users cannot tell the difference โ which tells you something about lever two of cost optimisation generally.
- Shrink the work. Shorter context, tighter output caps, summaries instead of full generations.
- Defer it. Queue the request for off-peak batch processing at half price: "your report will be ready in the morning" is an acceptable answer for non-interactive features.
- Block with a clear path. If you must block, the message names the limit, the reset date, and the upgrade โ one click away.
Whichever you choose, tell the user what is happening. Silent degradation reads as "the AI got worse", and that rumour is expensive.
Meter from day one, even if you enforce nothing. Quotas, alerts, pricing tiers and degradation strategies are all one migration away once the usage data exists โ and impossible to retrofit honestly once it does not.
The same pattern beyond Laravel
Nothing here is framework-specific: a gate before the call, actual usage recorded after it, counters in something fast, a ledger in something durable. I have implemented the identical shape in Python services with decorators and Celery. The Laravel version just happens to be pleasantly compact, and it slots straight into the AI features I build for SaaS clients.
Adding LLM features to a multi-tenant product and want the cost side under control before launch? This is exactly what I build โ get in touch.