renoki-co/cashier-register
Cashier Register is a Laravel package that extends Laravel Cashier for Stripe with plan and feature management, so you can define subscription tiers, gate features, enforce per-plan limits, and build a SaaS billing flow with minimal code.
renoki-co/cashier-register sits on top of Laravel Cashier (Stripe) and gives you a first-class model for features (things you meter: API calls, seats, exports) and plans (bundles of features with a quota value per period). Day-to-day, it replaces hand-rolled usage columns on the users table with a queryable, auditable quota ledger.
Minimal setup:
composer require renoki-co/cashier-register
php artisan vendor:publish --provider="RenokiCo\CashierRegister\CashierRegisterServiceProvider"
php artisan migrate
Prerequisites worth confirming before you start: Cashier is installed and configured, your billable model uses Billable, and the package's quota trait (see the README's HasQuotas section) is wired onto the billable model if you want the $user->... sugar in addition to the subscription-level API.
Where to look first, in order:
config/cashier-register.php — table names and model overrides. Decide now whether you'll subclass Feature/Plan, because retrofitting custom models after usage data exists is painful.plan ↔ feature with value) and the usage table. Understanding that quota lives on the pivot, not on the feature, prevents most day-one confusion.RenokiCo\CashierRegister\Models\Feature and Plan — the whole domain is these two plus the usage rows.The first use case to build is almost always a metered endpoint: seed a Pro plan with an api-calls feature at 10,000, attach it to your billable model via Cashier, then guard the endpoint with a quota check and consume on success.
Seed plans and features idempotently. Quotas are catalog data and belong in seeders, not in admin UI code you write twice:
$feature = Feature::updateOrCreate(
['slug' => 'api-calls'],
['name' => 'API Calls']
);
$plan = Plan::updateOrCreate(
['stripe_id' => 'pro'],
['name' => 'Pro']
);
$plan->features()->syncWithoutDetaching([
$feature->id => ['value' => 10000],
]);
syncWithoutDetaching keeps re-running seeders safe; a plain attach will happily create duplicate pivot rows and your quota lookups will start returning whichever row the query hits first.
Guard-then-consume in the request path. Check before doing the expensive work, consume after it succeeds:
$subscription = $user->subscription('default');
abort_unless($subscription->canConsumeQuota('api-calls', 1), 429);
$result = $this->doExpensiveThing();
$subscription->consumeQuota('api-calls', 1);
Consuming before the work means a failed job still burns quota; checking without consuming means free traffic.
Keep Stripe as the source of truth for plan assignment. Use $user->subscription('default')->swap($plan->stripe_id) (or Cashier's own swap) rather than setting the local plan column directly, and reconcile plan changes from customer.subscription.updated webhooks. A plan written only locally will silently disagree with what the customer is actually being billed for.
Decide your overage policy up front. Two valid postures: hard-block (canConsumeQuota gates everything, overage is an error state), or soft-meter (always consume, flag isOverQuota(), bill the excess). The soft-meter posture is where the 6.0.2 changes matter — see below.
Use the two usage accessors for two different questions. getUsedQuota() answers "how much of this period's allowance is gone" (resets with the billing period). getTotalUsedQuota() — new in 6.0.2 — answers "how much has this subscription ever consumed," read from the non-resetting used_total column. Use the first for quota gating and progress bars, the second for lifetime reporting, overage invoicing, and analytics.
Middleware for the common case. Wrap the guard in a middleware keyed by feature slug so controllers stay clean, and attach it per-route-group rather than globally so webhook and health endpoints don't consume quota.
used_total. Releases before 6.0.2 double-incremented used_total once a subscription was already over quota (PR #50). If you're on 6.0.0/6.0.1 and bill from used_total, audit or recompute those values after upgrading — the fix stops future drift, it does not repair existing rows.getTotalUsedQuota() does not exist before 6.0.2. Pin "renoki-co/cashier-register": "^6.0.2" in composer.json; a ^6.0 constraint can resolve to a version without the method and you'll get a BadMethodCallException in production only.$feature->value does not exist in the way people expect. Always read the quota through the subscription/plan helper, and remember a feature can carry a different value on each plan.canConsumeQuota followed by consumeQuota is a check-then-act race. Under load, wrap consume in a transaction with lockForUpdate on the usage row, or accept a small overage overshoot by design rather than pretending the guard is atomic.subscription.features or cached the quota for a request, the numbers won't move after a consume. Call ->fresh() or clear the cache entry on the write path.Plan extends RenokiCo\CashierRegister\Models\Plan) and be registered in cashier-register.php; extending the wrong base or forgetting the config entry gives you a model that silently bypasses package behavior.$user->subscription('default'), then inspect getUsedQuota(), getTotalUsedQuota(), and the raw usage row. If they disagree with your dashboard, the dashboard is usually reading the pivot quota while the code is reading a cached relation.How can I help you explore Laravel packages today?