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.
How to install
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.
use EduLazaro\Larameter\Concerns\HasCredits;
class Organization extends Model
{
use HasCredits;
}
The account row appears the first time you touch it.
What are allowances
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:
'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:
'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.
Rolling and fixed are not interchangeable
Here is a description of the basic usage window terms:
anchordecides when the next window starts, and picking the wrong one is the kind of bug users write in to complain about.rollingstarts 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.fixedsits 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 when does my week reset needs an answer that is not depends when you last stopped. A dormant account gets 1 allowance back on its return, not 4.
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.
Nothing to synchronise
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:
- The window resets on its own grid, lazily, the next time credits are charged. An account whose month began on the 28th resets on the 28th, for ever, without anybody telling it to.
- The plan is worked out on every request. Stop paying and the provider stops finding a subscription, so the allowance drops to whatever the next provider says. Pay 4 days late and it comes back mid-window.
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.
Charging: a price by name, or a rate per unit
2 doors. An action has a fixed price:
$org->credits()->charge('create_form', actor: $user);
And consumption is priced per unit in and out:
$org->credits()->meter('gpt-4o', 'token', $inputTokens, $outputTokens);
Everything is expressed in credits, rates included:
'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:
$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.
What you sell sits outside every window
Top-ups are not consumption, so they cannot be expressed as a sum of what was spent. They are their own table:
$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.
Plans are dynamically resolved
Add HasPlans and the plan is resolved by a list of providers, tried in order, first answer wins.
use EduLazaro\Larameter\Concerns\HasCredits;
use EduLazaro\Larameter\Concerns\HasPlans;
class Organization extends Model
{
use HasCredits, HasPlans;
}
'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.
$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:
'plans_from' => 'plans.tiers',
Using meters to set ceilings
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
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];
}
$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.
The usage screen
Reading a window never opens one, so a screen is free to ask for all of them:
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.
Wrapping up
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
👉 The source: github.com/edulazaro/larameter
Top comments (0)