vimatech / laravel-quotas
Feature entitlements and usage quotas for Laravel SaaS applications. Works on top of Laravel Cashier, or standalone.
Requires
- php: ^8.3
- illuminate/cache: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan: ^2.0
Suggests
- laravel/cashier: Charge for subscriptions with Stripe and resolve entitlements from them (cashier-stripe resolver).
- laravel/cashier-paddle: Charge for subscriptions with Paddle and resolve entitlements from them (cashier-paddle resolver).
README
Feature entitlements and usage quotas for Laravel SaaS applications.
This package does not charge anyone. Laravel Cashier already does that, and does it well: checkout, payment methods, proration, dunning, invoices, the Stripe billing portal. What Cashier does not answer is the question your application asks on every request — is this account allowed to do this, and has it used up its allowance?
That is the entire job here. You keep Cashier for the money; this package reads whichever subscription Cashier holds, maps it to a plan, and enforces the plan's features and quotas locally.
And it is not Pennant. Laravel Pennant decides whether a flag is on for a user, and it is the right tool for that. It does not count. There is no usage counter, no allowance, no billing period, no atomic ceiling. Pennant opens or closes the door; this package counts how many times you went through it, and closes the door when the plan says you are done.
What you get
- Feature gates —
$user->hasFeature('api'), plus route middleware. - Usage quotas — counters enforced atomically under a row lock, so concurrent requests cannot both pass the check and overshoot the limit.
- Automatic periods — allowances roll over on the subscription's anniversary, lazily on read and through a scheduled sweep.
- A plan catalogue — features, limits and prices, mapped to your provider's price ids.
- Any billable model — usage is polymorphic, so
TeamandOrganizationwork as well asUser. - Cashier Stripe, Cashier Paddle, or neither — one small interface decides where subscriptions come from.
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
composer require vimatech/laravel-quotas
php artisan vendor:publish --tag=quotas-config php artisan vendor:publish --tag=quotas-migrations php artisan migrate
Quick start
1. Add the trait to your billable model
use VimaTech\LaravelQuotas\Traits\HasQuotas; class User extends Authenticatable { use HasQuotas; // sits alongside Laravel\Cashier\Billable without colliding }
2. Choose where subscriptions come from
// config/quotas.php 'subscriptions' => [ 'resolver' => 'cashier-stripe', // or 'cashier-paddle', or 'local' ],
| Resolver | Subscriptions live in | Use when |
|---|---|---|
cashier-stripe |
laravel/cashier |
Stripe charges your customers |
cashier-paddle |
laravel/cashier-paddle |
Paddle charges your customers |
local |
this package's own table | seats granted by hand, self-hosted licences, internal tenants, free tiers |
You may also point resolver at your own class implementing
SubscriptionResolverInterface.
How the Cashier resolvers work — and what they never do
This package never talks to Stripe or Paddle and needs none of their API keys. Verification is layered, and each layer owns its own question:
- The provider knows whether the card went through, and says so by webhook.
- Cashier receives those webhooks and keeps its local
subscriptionstable in sync. Itsvalid()method is the authority on whether a subscription currently grants access. - This package reads that local table on each request — no network call, ever — and translates the subscribed price ids into one of your plans.
Two consequences worth knowing before you wire it up:
- The provider keys live in Cashier's configuration, not here. If a
Cashier setup works, this package works on top of it with nothing more than
the
resolverline above andgatewayPriceson your plans. - The freshness of entitlements is exactly the freshness of Cashier's
webhooks. If those are not configured, Cashier's table stops moving and this
package will faithfully enforce stale data — the weak link in that setup is
the webhook, not the quota. Follow Cashier's own installation guide,
including
cashier:webhook.
3. Define plans
A plan is a catalogue entry: what it grants, how much of it, and which provider prices it is sold under.
use VimaTech\LaravelQuotas\DTOs\PlanData; use VimaTech\LaravelQuotas\Managers\PlanManager; app(PlanManager::class)->create(new PlanData( name: 'Pro', slug: 'pro', monthlyPrice: 2900, // display only; the provider owns real pricing features: ['agents', 'api', 'webhooks'], limits: ['executions' => 1000, 'agents' => 10], gatewayPrices: ['stripe' => ['price_pro_monthly', 'price_pro_yearly']], trialDays: 14, ));
gatewayPrices is the link between Cashier and this package: monthly and yearly
prices for the same plan grant the same features. A limit of -1 means
unlimited; a feature absent from limits is unlimited too.
4. Sell the plan through Cashier
Nothing in this package touches the checkout — that stays Cashier's:
return $user->newSubscription('default', 'price_pro_monthly') ->trialDays(14) ->checkout([ 'success_url' => route('billing.success'), 'cancel_url' => route('billing.cancel'), ]);
5. Gate features and count usage
$user->hasFeature('api'); // does the plan grant it at all? $user->canUse('executions'); // granted, and allowance not spent? $user->hasReachedLimit('executions'); $user->incrementUsage('executions'); // throws if it would exceed the limit $user->incrementUsage('executions', 5); $user->usageOf('executions'); // 42 $user->remainingUsage('executions'); // 958, or null when unlimited $user->hasUnlimited('executions'); // bool $user->currentPlan(); // Plan|null, whoever holds the subscription $user->isSubscribed(); $user->onTrial();
incrementUsage() is the one that enforces. It re-reads the counter under a row
lock, so two concurrent requests on the last unit cannot both succeed:
public function store(Request $request) { $request->user()->incrementUsage('executions'); // UsageLimitExceededException // ... }
6. Protect routes
use VimaTech\LaravelQuotas\Middleware\EnsureFeatureIsAvailable; use VimaTech\LaravelQuotas\Middleware\EnsureSubscriptionIsActive; Route::middleware(EnsureSubscriptionIsActive::class)->group(function () { Route::middleware(EnsureFeatureIsAvailable::class.':agents')->group(function () { // granted the feature, and allowance not spent }); });
Both middlewares share one memoised plan lookup per request, so stacking them costs a single subscription resolution.
They distinguish three refusals, because they are three different screens and two of them are a sale:
| Situation | API | Browser |
|---|---|---|
| Not authenticated | 401 |
redirect to login |
| Plan does not grant the feature | 403 feature_not_in_plan |
redirect to upgrade_route |
| Allowance spent | 402 limit_reached |
redirect to upgrade_route |
Set quotas.middleware.upgrade_route to your pricing route and a turned-away
browser lands there with a quotas-error flash message. Leave it null and the
status code stands on its own.
How quotas reset
Allowances are measured from the subscription's anniversary, not from the calendar. Subscribe on the 20th and the allowance returns on the 20th — not on the 1st because the month happened to turn over. An anniversary on the 31st clamps to the last day of shorter months, the same rule providers apply.
Resets happen two ways:
- Lazily, the first time a counter is read or incremented in a new period. This is what makes correctness independent of your scheduler.
- Through a sweep, so counters nobody touches also return to zero and dashboards read correctly:
php artisan quotas:reset
The command is registered on the scheduler daily. Turn that off with
quotas.quotas.schedule_reset if you would rather run it yourself.
'quotas' => [ 'reset_interval' => 'monthly', // daily, weekly, monthly, yearly, manual 'schedule_reset' => true, // Features that follow their own cadence instead of the default — // AI tokens back every week next to exports that stay monthly: 'feature_intervals' => ['ai_tokens' => 'weekly'], ],
Periods are measured from current_period_start — the date the customer is
actually billed on — falling back to when the subscription was created. That
distinction matters after a plan change: providers reset the billing cycle on
proration, and anchoring to the creation date would drift the quota reset away
from the invoice date permanently.
manual never rolls over on its own — you call $user->resetUsage('feature').
Standing limits: counting things that exist, not things consumed
Not every number in a pricing grid is a consumable. "2 000 AI tokens a month" is one — it spends down and comes back. "3 projects" is not: deleting a project must free a slot immediately, and no period should ever refill it.
Usage counters only go up and reset by period, so they are the wrong tool for standing limits. For those, the plan carries the ceiling and your own tables carry the count:
$limit = $user->currentPlan()?->getLimit('projects') ?? 0; if ($limit !== Usage::UNLIMITED && $user->projects()->count() >= $limit) { throw FeatureNotAvailableException::limitReached('projects'); }
Deletion needs no quota code at all — the count drops, the slot is free.
Without a payment provider
With resolver => 'local', this package stores subscriptions itself. Useful for
manually granted seats, internal tenants and free tiers, and the only resolver
supporting several billable types at once:
use VimaTech\LaravelQuotas\Enums\BillingInterval; $user->subscribe('pro'); // monthly $user->subscribe('pro', BillingInterval::Yearly); // yearly $user->swapPlan('enterprise'); $user->cancelSubscription(); // at the end of the period paid for $user->cancelSubscription(immediately: true); $user->resumeSubscription(); $user->currentSubscription(); // Subscription|null
The interval is what "cancel at period end" measures against: a yearly subscriber who cancels on day one keeps the year they were charged for.
Two refusals are deliberate. subscribe() throws
AlreadySubscribedException when an active subscription already exists — two
of them for one billable is not a state this package can reason about, and a
double-clicked form is enough to produce it. And both subscribe() and
swapPlan() throw PlanNotFoundException for a deactivated plan: a plan you
have taken off the catalogue must not stay purchasable by slug. Plans already
subscribed to keep resolving after they are retired, so nobody loses what they
bought.
These write to the local table, so they throw
LocalSubscriptionsDisabledException under a Cashier resolver — where creating
and cancelling subscriptions is Cashier's job, not this package's. That refusal
is deliberate: a local "subscription" nobody is paying for is exactly the bug
this design exists to prevent.
Events
| Event | Dispatched when |
|---|---|
UsageLimitReached |
a counter reaches its limit |
UsageReset |
a counter rolls over into a new period |
SubscriptionCreated |
a local subscription is created |
SubscriptionCancelled |
a local subscription is cancelled |
PlanChanged |
a local subscription's plan is swapped |
The last three fire for the local resolver only. Under Cashier, listen to
Cashier's own webhook events.
Type-hinting a billable
Two interfaces describe what the trait adds, so your own code can be explicit about which half it needs:
use VimaTech\LaravelQuotas\Contracts\ManagesLocalSubscription; use VimaTech\LaravelQuotas\Contracts\QuotaAware; class User extends Authenticatable implements QuotaAware, ManagesLocalSubscription { use HasQuotas; }
QuotaAware is the read side — plan, features, usage — and behaves the same
whoever owns the subscription. ManagesLocalSubscription is the write side, and
throws under a Cashier resolver. Implement whichever you actually use.
Writing your own resolver
Four methods, no payment logic:
use VimaTech\LaravelQuotas\Contracts\SubscriptionResolverInterface; final class LicenceServerResolver implements SubscriptionResolverInterface { public function resolvePlan(Model $billable): ?Plan { /* ... */ } public function isSubscribed(Model $billable): bool { /* ... */ } public function onTrial(Model $billable): bool { /* ... */ } public function anchor(Model $billable): ?CarbonImmutable { /* ... */ } }
'subscriptions' => ['resolver' => LicenceServerResolver::class],
anchor() returns the date quota periods are measured from. Derive it from data
you already hold — it is consulted on ordinary requests and must never make a
network call.
If your resolver also reads this package's own subscriptions table — a Paddle
resolver with an AppSumo-lifetime fallback, a Stripe resolver with manually
granted internal tenants — declare the LocalSubscriptionSource marker as
well. That is what authorises subscribe(), swapPlan() and
cancelSubscription() to write the local table while your resolver is active;
without it they throw, because a resolver reading a payment provider alone must
not be able to hand out local subscriptions nobody pays for.
final class PaddleWithSumoResolver implements LocalSubscriptionSource, SubscriptionResolverInterface { // try Paddle first, fall back to the local table }
Configuration
See config/quotas.php. The keys that matter most:
'subscriptions' => [ 'resolver' => env('QUOTAS_RESOLVER', 'local'), 'cashier_type' => env('QUOTAS_CASHIER_TYPE', 'default'), ], 'quotas' => [ 'reset_interval' => env('QUOTAS_RESET_INTERVAL', 'monthly'), 'schedule_reset' => env('QUOTAS_SCHEDULE_RESET', true), ], 'middleware' => [ // Name your pricing route and a refused browser request lands there // instead of on an error page. API requests always get the status code. 'upgrade_route' => env('QUOTAS_UPGRADE_ROUTE'), ], 'cache' => [ 'enabled' => true, 'ttl' => 60, // usage counters are read far more often than they change ],
Writes invalidate a cached counter immediately. The TTL only bounds how long a counter changed outside this package can look stale.
Testing
composer test # Pest composer analyse # PHPStan level 6 composer format # Pint
The Cashier resolvers are covered by fixtures reproducing Cashier's surface, so the suite runs without Cashier installed.
License
The MIT License (MIT). Please see License File for more information.
Credits
Built and maintained by Vimatech. Created by Adel Zemzemi.