# Credits, plans and quotas in Laravel with Larameter

> Source: <https://dev.to/edulazaro/credits-plans-and-quotas-in-laravel-with-larameter-534c>
> Published: 2026-08-24 03:21:48+00:00

If your app sells an allowance, a number of credits a month, or a number of documents, or a number of anything. Whatever it is, you end up writing a balance somewhere, a reset when the period rolls over, a check before the expensive call, and a usage screen that has to agree with all of it.

None of that is hard on its own. What gets you is that the pieces drift. The plan says a thousand a month, the reset runs on the first of the month, the subscription renews on the 18th, and the screen sums a table that the charging code stopped writing to two features ago. And then somebody adds a weekly cap and now there are two numbers per plan to keep consistent, times seven plans.

So after repeating the same on many apps, just created Larameter. It's basically what you see in Claude or OpenAI subscription plans. It meters credits against a plan, enforces ceilings on things that exist rather than things that are spent, and works out which plan an account is on instead of storing it.

Just install the package via composer as usually:

```
composer require edulazaro/larameter
php artisan vendor:publish --tag=larameter-config
php artisan vendor:publish --tag=larameter-migrations
php artisan migrate
```

Then add the trait to whatever you bill. An organisation, a user, a workspace: the package does not care, and it does not need a column on your table.

``` php
use EduLazaro\Larameter\Concerns\HasCredits;

class Organization extends Model
{
    use HasCredits;
}
```

The account row appears the first time you touch it.

One period is rarely enough. A monthly figure alone lets a bad afternoon eat the month, so you want a weekly cap on top, and maybe a per-session one. Declare those windows once:

``` js
'windows' => [
    'session' => ['minutes' => 300, 'anchor' => 'rolling', 'share' => 0.04],
    'weekly'  => ['days' => 7,      'anchor' => 'fixed',   'share' => 0.25],
    'monthly' => ['months' => 1,    'anchor' => 'fixed',   'share' => 1],
],
```

And then a plan grants **1 figure**, which every window takes a share of:

``` js
'plans' => [
    'free' => ['credits_monthly' => 1_000],
    'pro'  => ['credits_monthly' => 50_000, 'limits' => ['members' => 25]],
],
```

50,000 a month is 12,500 a week and 2,000 a sitting, and raising the plan raises all 3 at once. The alternative, a figure per window per plan, is 7 plans times 3 numbers to keep consistent: the day somebody doubles the monthly and forgets the weekly, the weekly quietly becomes the binding constraint and nothing tells you.

The tightest window is the one that binds. A window with no `share`

narrows nothing. Declare no windows at all and you have opted out of allowance metering entirely: usage is still recorded, nothing is refused, and only purchased credits mean anything.

Here is a description of the basic usage window terms:

** anchor** decides when the next window starts, and picking the wrong one is the kind of bug users write in to complain about.

** rolling** starts the moment credits are next spent after the old one expired, so the full length is always available. That is what a session wants: on a fixed grid, starting 10 minutes before a boundary hands somebody 10 minutes, and it reads as the product having robbed them.

** fixed** sits on a grid laid down from the first window and moves on whether it is used or not. That is what a week wants, because

One rule that falls out of this and matters more than it looks: **asking never opens a window**. For a rolling window the row is the clock, so an expired one is reported as full without being restarted. Otherwise opening the app to check your balance would burn the session before a word was typed.

There is no webhook here, and nothing to call when a subscription renews. A billing period and a credit window are separate clocks and neither has to know about the other:

Tying them together would break the ordinary case rather than improve it: an annual subscription usually grants a monthly allowance, and a window anchored to the billing period would reset it once a year.

2 doors. An action has a fixed price:

``` php
$org->credits()->charge('create_form', actor: $user);
```

And consumption is priced per unit in and out:

``` php
$org->credits()->meter('gpt-4o', 'token', $inputTokens, $outputTokens);
```

Everything is expressed in credits, rates included:

``` js
'prices' => ['create_form' => 2, 'send_email' => 1],

'rates' => [
    'gpt-4o' => ['input' => 25_000, 'output' => 100_000],
],
```

That reads as 25,000 credits per million input tokens. What a credit is worth in money is your business and the package never asks.

2 behaviours that read in opposite directions, on purpose. **An action you never priced is free**, because a package should not invent a price for something you did not write down. **A metered unit you never priced still costs something**, because the alternative is that metering an unknown model is free and the gap only shows up on your provider's invoice.

Before you spend, you can ask:

``` php
$org->credits()->allows();               // 1 credit?
$org->credits()->allows(250);            // 250?
$org->credits()->allows('send_email');   // enough for what that costs?
$org->credits()->price('send_email');    // what it costs
$org->credits()->meterPrice('gpt-4o', $in, $out);  // the same, uncharged
```

Charging itself does not refuse. An account with nothing left records the overdraft rather than leaving a turn half done, and the difference stays visible: every usage row stores the split between `credits_from_plan`

and `credits_from_purchased`

, and when they add up to less than `credits`

, that gap is the overdraft.

Top-ups are not consumption, so they cannot be expressed as a sum of what was spent. They are their own table:

``` php
$org->credits()->deposit(5_000, reason: 'purchase', source: $payment);
$org->credits()->deposit(500, reason: 'gift', note: 'launch promo');
$org->credits()->deposit(-200, reason: 'adjustment', note: 'duplicate charge');
```

Purchased credits survive every reset, and — this is the part that matters — what they pay for is **not counted against the windows**. You run out of session, you buy more, you carry on, and your week has not moved meanwhile.

The balance is stored rather than summed, which once you sell credits is not an optimisation but the model itself. It stays checkable anyway, because the deposits table is the ledger, and that is what you want the first time somebody asks why an account has 5,000 credits.

Add `HasPlans`

and the plan is resolved by a list of providers, tried in order, first answer wins.

``` php
use EduLazaro\Larameter\Concerns\HasCredits;
use EduLazaro\Larameter\Concerns\HasPlans;

class Organization extends Model
{
    use HasCredits, HasPlans;
}
js
'plan_providers' => [
    PlanProviders\ForcedPlanProvider::class,    // a column of yours, set by hand
    PlanProviders\CashierPlanProvider::class,   // the subscription, by price id
    PlanProviders\StoredPlanProvider::class,    // credits()->setPlan(), then the default
],
```

The order is the policy. Forced before Cashier means a plan somebody set by hand for a partner or a demo beats what Stripe thinks, because a person decided it deliberately. `CashierPlanProvider`

is inert without Cashier installed, so an app that only sells bundles pays nothing for leaving it there. For Paddle or anything else, implement the contract and add it to the list.

``` php
$org->plan()->handle;              // 'pro'
$org->plan()->exists;              // false when no provider answered
$org->plan()->allows('api_access');
$org->plan()->is('pro');
```

You can also point the package at your own plans file, so the commercial half of a plan lives next to the metered half:

``` js
'plans_from' => 'plans.tiers',
```

Credits are spent and come back. Seats and projects are different: a standing count of what exists, and the plan says how many at once.

```
php artisan make:meter MemberMeter Organization
php
class MemberMeter extends Meter
{
    public string $handle = 'members';

    public function count(): int
    {
        return $this->meterable->members()->count();
    }
}
```

List it on the model, and ask:

```
class Organization extends Model
{
    use HasCredits, HasMeters;

    protected array $meters = [MemberMeter::class, ProjectMeter::class];
}
php
$org->quota()->allows('members');       // room for one more?
$org->quota()->allows('members', 4);    // inviting 4 at once
$org->quota()->summary();               // the usage screen
```

**A meter is a class and not a number you pass in**, and that is the whole point. The app this came from had a 1-seat plan, showed it on the usage screen, and never checked it when inviting: the cap was enforced for projects and forgotten for members, because enforcing it meant every caller had to remember to count first. Once counting lives in a class, no caller can forget.

A resource with no meter is unlimited, and so is a `limits`

key you never listed. These are restrictions, and a package you just installed should not start refusing to create users on its own opinion. `-1`

is unlimited explicitly, which is not the same as `0`

.

Reading a window never opens one, so a screen is free to ask for all of them:

``` php
foreach ($org->credits()->windows() as $window) {
    $window->key;            // 'weekly'
    $window->allowance();    // 12_500
    $window->used();         // 12_000
    $window->remaining();    // 500
    $window->percentUsed();  // 96.0
    $window->startedAt();    // what to count from, per person or per feature
    $window->endsAt();       // the Monday coming, or null if none is running
}
```

`endsAt()`

is not what the row says once a fixed window has expired. The grid went on without it, so a row claiming a Monday 3 weeks back answers with the Monday ahead, which is the only answer a screen can show.

An allowance per window with 1 figure per plan, top-ups that outlive every reset, ceilings enforced by classes that know how to count, and a plan resolved on every request instead of stored and synchronised. Your billing keeps the parts that are yours (what a credit costs, what a plan is worth, who gets a courtesy account... etc) and stops re-implementing the parts that are the same in every project.

👉 The package: [packagist.org/packages/edulazaro/larameter](https://packagist.org/packages/edulazaro/larameter)

👉 The source: [github.com/edulazaro/larameter](https://github.com/edulazaro/larameter)
