zaber-dev/laravel-quota
Laravel Quota tracks and enforces application-level usage budgets across daily, weekly, monthly, or custom periods. Store quotas in cache or database, attach them to Eloquent models, protect routes with middleware, and extend with custom backends. Complements RateLimiter.
composer require zaber-dev/laravel-quota
# Publish config + migration (the provider tag publishes both)
php artisan vendor:publish --provider="ZaberDev\Quota\QuotaServiceProvider"
# or, more surgically:
php artisan vendor:publish --tag=quotas-config
php artisan vendor:publish --tag=quotas-migrations
php artisan migrate # only needed for the `database` driver
Then pick your default driver. For a first rollout, leave QUOTA_DRIVER=cache and let it ride on your existing Redis/Memcached store; switch individual actions to database later with ->using('database').
QUOTA_DRIVER=cache
QUOTA_CACHE_STORE=redis
Four files do almost all the work — read them before writing anything:
config/quotas.php — default driver, cache store/prefix, table name, event dispatch flag.ZaberDev\Quota\Facades\Quota + the fluent builder — the entire public surface.ZaberDev\Quota\QuotaInfo DTO — used, limit, remaining(), periodStart, periodEnd. This is what you'll hand to your UI and API responses.ZaberDev\Quota\Http\Middleware\CheckQuota — read the source to confirm exactly when the unit is deducted (the README says after a 2xx/3xx response; verify the mechanism before you rely on it).The repo also ships LEARN.md and an agent skill (skills/laravel-quota/SKILL.md) that is effectively a condensed API reference — useful even for humans.
Attach the trait to the model that owns the budget:
use ZaberDev\Quota\HasQuotas;
class User extends Authenticatable
{
use HasQuotas;
}
Then gate one controller action:
use ZaberDev\Quota\Exceptions\QuotaExceededException;
public function store(ExportRequest $request)
{
try {
$info = $request->user()
->quota('pdf_exports')
->limit(25)
->perMonth()
->consume();
} catch (QuotaExceededException $e) {
return response()->json([
'message' => 'Monthly export limit reached.',
'resets_at' => $e->getQuotaInfo()?->periodEnd,
], 429);
}
// ...dispatch the export
return ['remaining' => $info->remaining()];
}
That's the whole value proposition in ten lines: calendar-boundary rollover with no cron job, a structured DTO, and a 429 that Laravel renders for free.
->limit(50) everywhereThe limit is supplied at call time, not stored on the quota. That means the same action can be evaluated with limit(25) in one controller and limit(50) in another, and both will report different remaining() values for the same usage counter. Treat this as a bug waiting to happen and define limits once:
enum QuotaBudget: string
{
case PdfExports = 'pdf_exports';
case AiTokens = 'ai_tokens';
public function limit(): int
{
return match ($this) {
self::PdfExports => 25,
self::AiTokens => 100_000,
};
}
}
Then wrap the builder in a small app-level service so views and controllers never touch the facade directly:
final class Quotas
{
public static function for(QuotaBudget $budget, Model|string $target): QuotaBuilder
{
return Quota::for($budget->value, $target)->limit($budget->limit());
}
}
This also gives you a one-file escape hatch if you ever migrate off the package.
block() for anything expensiveFor PDF rendering, AI calls, video jobs, or anything that costs real money, block() is the pattern that matters most. It takes a lock, runs the callback, and only deducts on success:
$info = Quotas::for(QuotaBudget::PdfExports, $user)
->perMonth()
->block(fn () => $pdfService->generate($user), amount: 1, lockSeconds: 60);
Use it to get two things at once: double-submit protection and no-charge-on-failure. Plain consume() charges you before the work happens and gives you no concurrency protection.
For a route where one successful request = one unit, use the middleware and write zero code:
Route::post('/api/query', [ApiController::class, 'query'])
->middleware('quota:api_query,1000,day');
Its success-only behavior is genuinely nicer than hand-rolled checks: a 422 from validation doesn't burn the user's budget. But note the middleware only ever consumes 1 unit and targets $request->user() ?? $request->ip() — so anything with variable cost (token counts, file counts, seat invites) belongs in the controller.
| Action | Driver | Reason |
|---|---|---|
| API request counts, per-minute/hour limits | cache |
Hot path, tolerate loss on cache flush |
| Billing-tier budgets, audit-relevant usage | database |
Survives restarts, queryable, prunable |
| Anything you'll invoice for | database |
You need the row, not just the counter |
Quota::for('api_calls', $apiKey)->using('cache')->limit(5000)->perDay()->consume();
Quota::for('monthly_exports', $user)->using('database')->limit(50)->perMonth()->consume();
remaining() in the UIQuotaInfo is immutable and carries periodStart/periodEnd, which is exactly what you need for "23 of 25 exports used — resets 1 March" copy, or for X-Quota-Remaining / Retry-After headers on API responses. Cache the read (remaining() hits the store) rather than calling it in a loop over a collection.
There's no documented Quota::fake(). Use Laravel's own tools:
config(['quotas.default' => 'cache', 'quotas.drivers.cache.store' => 'array']);
$this->travelTo(Carbon::parse('2026-03-15'));
For the database driver, RefreshDatabase plus the published migration gives you real rows to assert against. Assert the rollover explicitly — consume to the limit, travelTo(next month), assert used() === 0. That's the behavior you're buying this package for; prove it works.
QuotaConsumed, QuotaExceeded, and QuotaReset are strongly typed. Wire QuotaExceeded to your alerting or upsell-trigger pipeline and QuotaConsumed to usage metering — that's a free usage-analytics stream you'd otherwise build yourself.
consume() is atomic. The README explicitly sells block() as the anti-race-condition tool, which implies plain consume() (and therefore middleware-protected routes) can overshoot under concurrency. For hard limits, use block() or the database driver and verify the locking behavior against your cache store — the array store in tests will never surface this.lockSeconds must exceed your worst-case callback duration. If the lock expires mid-render, a second request enters and you've defeated the purpose. Set it generously (60–120s), or move long work to a queued job: consume/reserve at dispatch, refund with decrement()/reset() on job failure. There's no built-in reservation/refund primitive, so build that explicitly.remaining() returns PHP_INT_MAX for unlimited. Any arithmetic or UI string built on it will look absurd. Branch on $info->limit or use unlimited() deliberately and handle display separately.perDay() is your day, not the user's. For a globally distributed user base, "resets at midnight" means midnight in config('app.timezone') — put that in your UI copy and confirm it's what your users expect.perDay() to perMonth() starts a fresh counter namespace; historical daily usage doesn't roll up. Decide the period once, per action, and never change it without a migration plan.cache:clear, Redis eviction under memory pressure, or a store change silently resets everyone's budget. Never use cache for anything with a billing or contractual consequence.database. Without Schedule::command('model:prune', ['--model' => Quota::class])->daily(), the quotas table grows forever. One line, easy to forget..ai/skills/laravel-quota from the provider's boot(). On a read-only or containerized production filesystem this is at best a wasted stat call and at worst an exception. Verify it degrades gracefully, or strip the feature in your own fork/.env.ZaberDev\Quota\Facades\Quota and ZaberDev\Quota\Models\Quota exist, and Quota is a name many apps already use. Alias the facade in config/app.php and only import the model in the pruning schedule, not in application code.Quota::for()->perMonth()->consume() leak into 40 controllers. Confine it to the thin wrapper service described above — at that scale, replacing it with a quotas table and a lockForUpdate transaction is an afternoon, not a refactor.periodStart/periodEnd from the DTO first. Nine times out of ten it's a test frozen with Carbon::setTestNow(), an app timezone mismatch, or a request silently hitting a different driver than you assumed (->using() on one call site only).Quota::extend('dynamodb', fn ($app) => new MyDriver(...)) in a service provider is the sanctioned way to add a backend. Implement QuotaDriverContract and keep the driver's increment atomic — the package's correctness under load is only as good as your implementation.How can I help you explore Laravel packages today?