Windows
An allowance is always an allowance per something, and one period is rarely enough. A monthly figure on its own lets a bad afternoon eat the month; a weekly cap on top is the brake, and a session cap is what stops one runaway loop from taking the week with it.
'windows' => [ 'session' => ['minutes' => 300, 'anchor' => 'rolling', 'share' => 0.04], 'weekly' => ['days' => 7, 'anchor' => 'fixed', 'share' => 0.25], 'monthly' => ['months' => 1, 'anchor' => 'fixed', 'share' => 1], ],
The tightest window is the one that binds. Spending draws against all of them at once, and headroom() is the smallest of what is left.
Length
Built from minutes, hours, days and months, combined freely:
'trial' => ['days' => 14, 'hours' => 12],
Weeks are not a unit here, so that there is one obvious way to write seven days. A window that declares no length at all throws an InvalidArgumentException naming the window, rather than quietly behaving as if it had one.
Share
share is the part of a plan's allowance that fits in this window.
A plan grants one figure and every window takes a slice of it. Fifty thousand a month is twelve thousand five hundred a week and two thousand a sitting, and raising the plan raises all three at once.
The alternative, a figure per window per plan, is seven plans times three numbers to keep consistent. The day somebody doubles the monthly and forgets the weekly, the weekly silently becomes the binding constraint and nothing anywhere says so.
A window with no share narrows nothing: it gets the whole allowance. Shares are not required to add up to anything, and are not checked against each other, because there is no arrangement that would be wrong in every application.
Anchor
anchor decides when the next window starts, and the two values are not interchangeable.
rolling |
The next window starts the moment credits are spent after the old one expired, so the full length is always available. |
fixed |
Windows sit on a grid laid down from the first one, and the grid moves on whether it is used or not. |
rolling is what a session wants. On a fixed grid, somebody who starts ten minutes before a boundary gets ten minutes, and that reads as the product having robbed them.
fixed is what a week wants, because when does my week reset needs an answer that is not depends when you last stopped. It also means a dormant account gets one allowance back on its return and not four, since the grid ran on without it and only the current slot counts.
fixed is the default when the key is absent.
Asking never opens a window
This is the rule the whole design hangs from.
For a rolling window the row is the clock. Creating one starts the five hours. So if merely asking how much is left created it, opening the application to look at your balance would burn the session before a word was typed.
Windows are therefore created on charge and never on read. An expired window is reported as full without being restarted:
Carbon::setTestNow('2026-01-10 09:00:00'); $org->credits()->charge('thing', credits: 50); // session opens, 50 spent Carbon::setTestNow('2026-01-10 20:00:00'); // eleven hours later $org->credits()->remaining(); // 50: reported full // ...and the row still says it started at 09:00. Nothing moved.
The row moves on the next charge, and a rolling one then gets its whole length from the moment they came back.
Reading 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(); // the Monday just gone, or null if none is running $window->endsAt(); // the Monday coming, or null if none is running }
windows() always returns one WindowUsage per declared window, whether or not a row exists for it, so a usage screen does not have to handle the case of an account that has never spent anything.
Two null cases, and they mean different things. A rolling window that has expired is genuinely not running, and the next one begins whenever spending resumes, so startedAt() and endsAt() are both null. A window that has never been charged at all is the same situation seen from the other end.
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 three weeks back answers with the Monday coming, which is the only answer a screen can show.
Nothing to synchronise
A window's grid is fixed by when it started, and every reset lands one length later, for ever. An account whose month began on the 28th resets on the 28th, without anybody telling it to.
Which is why 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.
- 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 four days late and it comes back mid-window.
Tying the two 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 that once a year.
Opting out
Declare no windows at all and you have opted out of allowance metering:
'windows' => [],
Usage is still recorded, nothing is refused, headroom() is PHP_INT_MAX, and only purchased credits mean anything. That is the right shape for an application that sells bundles rather than subscriptions.