Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Quota Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up

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

Where to look first

Four files do almost all the work — read them before writing anything:

  1. config/quotas.php — default driver, cache store/prefix, table name, event dispatch flag.
  2. ZaberDev\Quota\Facades\Quota + the fluent builder — the entire public surface.
  3. ZaberDev\Quota\QuotaInfo DTO — used, limit, remaining(), periodStart, periodEnd. This is what you'll hand to your UI and API responses.
  4. 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.

First use case: a monthly export budget

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.


Implementation Patterns

Centralize the limits — don't scatter ->limit(50) everywhere

The 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.

Reserve with block() for anything expensive

For 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.

Middleware for the boring cases, builders for the interesting ones

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.

Choose drivers per action, not per app

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();

Surface remaining() in the UI

QuotaInfo 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.

Test with the array store and time travel

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.

Lean on the events for observability

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.


Gotchas and Tips

  • Don't assume 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.
  • Calendar periods use your app timezone. 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.
  • Period type is part of the key. Switching an action from 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 driver data is disposable. A 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.
  • Publish and schedule pruning if you use database. Without Schedule::command('model:prune', ['--model' => Quota::class])->daily(), the quotas table grows forever. One line, easy to forget.
  • The service provider writes files on boot. The Boost integration auto-syncs a skill into .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.
  • Naming collision risk. Both 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.
  • Adoption risk is real. ~110 stars, zero dependents, a single vendor org, and a version timeline that puts releases in the future. Do not let 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.
  • Debugging tip: when a quota "doesn't reset," dump 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).
  • Extension point: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator