Laravel Entitlements — When Subscription Active Isn't Seat Available
Your Stripe subscription can be current while every seat slot is taken — Laravel Entitlements models that gap instead of pretending billing status is capacity.

The uncomfortable truth about SaaS limits: most teams store who pays and what they can still use in the same mental bucket. Cashier says the workspace is subscribed. Your product still refuses the ninth invite because eight seats are bound to eight humans. Those are different columns — and the bug reports that start with "but they're on Pro" are almost always a category error, not a billing glitch.
Laravel Entitlements (masterix21/laravel-entitlements) splits the problem on purpose. Plans and licenses describe capacity; usages describe consumption; strategies decide whether a limit behaves like a seat, a device handshake, or a token pool.
It's not a Stripe replacement.
It's the machinery you wire after payment confirms — or beside a feature-gating subscription package when boolean limits aren't enough.
Paid ≠ entitled — why subscription rows aren't enough
A subscription row answers: is this customer in good standing with the payment provider? An entitlement graph answers: can this workspace bind one more device, seat, or token draw right now?
The failure mode is predictable. You gate invites on subscription->active(). Eight users already occupy eight seat slots. User nine gets a generic 403.
Support pulls Stripe — subscription is fine. Engineering greps for the wrong flag.
Subscription packages like Crumbls/subscriptions excel at plan membership, trial/grace windows, and resettable feature counters attached to a subscription slug. Middleware can block routes when canUseFeature('api-requests') fails. That model assumes features are named limits on the subscription itself — not polymorphic subjects that must release in two phases when hardware still holds a token.
Entitlements assumes your domain vocabulary lives in a backed enum. Each case picks a strategy.
Billing integration stays out of scope — same boundary Crumbls documents for payments. You listen for PlanAssigned or complete a changePlan() transition, then let Cashier own card charges.
Payment state first, capacity state second — never merged into one boolean.
Pick your layer — Entitlements vs feature-gating packages
Before you composer require, answer one question: does the limit bind to a subject, drain from a pool, or flip like a feature flag?
Feature-gating packages — Crumbls, the rinvex lineage it replaces — fit boolean toggles and numeric counters reset on an interval. "50 API calls this month" on the subscription row. Route middleware. Done.
Laravel Entitlements fits when consumption shapes diverge:
- Seats — one slot, one
Usermodel, release when they leave. - Devices — slot tied to hardware; revocation may lag behind the delete button.
- Token pools — drain N credits across licenses, FIFO by expiration.
If every limit is a slug with a string value on a pivot, you don't need this package.
If you already feel the if ($resourceType === 'device') branch creeping into recordFeatureUsage, you're the audience.
Anticipate the objection: "We'll grow into it later."
Entitlement types harden fast. Seat leaks and devices stuck in limbo are schema migrations with angry customers attached. Pick the model before the first enterprise deal with device caps.
Wire the type enum — slots, pools, and two-phase release
Install is standard Laravel package fare — PHP 8.2+, Laravel 11 or 12, publish config and migrations, point type_enum at your enum (Packagist, README):
composer require masterix21/laravel-entitlements
php artisan vendor:publish --tag="laravel-entitlements-config"
php artisan vendor:publish --tag="laravel-entitlements-migrations"
php artisan migrateThe design choice that matters more than install flags is the enum. Each entitlement type declares its strategy:
enum LicenseType: string implements EntitlementType
{
case Device = 'device';
case AiTokens = 'ai_tokens';
case Seat = 'seat';
public function strategy(): EntitlementStrategy
{
return match ($this) {
self::Device => new SlotStrategy(twoPhase: true),
self::AiTokens => new PoolStrategy(),
self::Seat => new SlotStrategy(),
};
}
}SlotStrategy binds one usage row to one subject — a user, a device record, anything morphable.
PoolStrategy drains a counter; a single consume() call can span multiple license rows ordered by expiration. twoPhase: true on devices means release is requestRelease() → external revocation → confirmRelease(), not an instant free slot — see the package events for the ReleaseRequested hook.
Worked example: one workspace, three shapes
Attach HasEntitlements to a Workspace model. Create a Pro plan with plan items: five devices (flexible false), ten seats, 100k AI tokens (flexible true). Assign:
Entitlements::assignPlan(
subscriber: $workspace,
plan: $proPlan,
startsAt: now(),
quantityOverrides: [
$tokenItemId => 500_000,
],
);assignPlan() creates a license group — anchor plus children linked by parent_id — so one assignment surfaces as one row in admin UIs. Recurring plans get ends_at = null; fixed-term plans compute end dates from the billing period.
Consumption follows the strategy:
Entitlements::consume($workspace, LicenseType::Seat, $user);
Entitlements::consume($workspace, LicenseType::AiTokens, $usage, amount: 1500);Check before you act:
Entitlements::can($workspace, LicenseType::AiTokens, 1500);
Entitlements::available($workspace, LicenseType::Seat);Miss the check and you catch NoEntitlementAvailableException at the controller edge — preferable to silently queue work that will fail billing reconciliation later.
Assign plans without rewriting history
Here's the counterintuitive bit: you don't edit license capacity in place. A license group is immutable once issued. Moving Pro → Basic closes the old group and opens a new one — append-only plan transitions. Audit trails stay honest; "what were they entitled to on March 3?" remains answerable.
Flexible plan items accept per-assignment quantity overrides — useful when sales negotiates a token bump without forking the catalog. Non-flexible items ignore overrides; the plan definition wins.
Consume, check, and release — the production paths
Slot consumption is idempotent in intent: one open usage per subject until released. Pool consumption decrements slot_used by amount, possibly splitting across licenses.
Two-phase device release is where teams either read the README or learn in production:
Entitlements::requestRelease($deviceUsage);
// dispatch job: revoke refresh token, MDM wipe, whatever your domain requires
Entitlements::confirmRelease($deviceUsage);Skip confirmRelease() and the slot sits in Releasing — capacity looks free in the UI while the counter still blocks new binds. The package emits ReleaseRequested for exactly this hook. Admin override exists via forceRelease() when support must unwedge a stuck row.
Plan transitions — downgrades that refuse to lie
changePlan() on the anchor license supports immediate switches and end-of-period deferrals. Schedule deferred transitions and register entitlements:apply-transitions on the scheduler — the command materializes pending rows when their window arrives (transitions docs, Laravel News overview).
Pre-validation runs before anything persists. Downgrade Pro → Basic when eight seats are occupied but Basic allows five? Domain exception — not a silent truncate of three users.
Target plan missing an entitlement type that still has open usages? Also blocked — you can't downgrade into a catalog that doesn't cover devices still bound in Releasing.
Roll-your-own schemas often UPDATE slot_total SET 5 and hope cron fixes the overflow.
Entitlements forces you to reconcile usages first. That strictness is the feature.
Exclusive plan categories reject overlapping active plans unless the category opts into allows_multiple_active_plans for add-on stacks.
Hang billing on events, not on entitlement tables
Neither Laravel Entitlements nor Crumbls charges cards. Cashier syncs Stripe subscription state. Your glue code listens:
invoice.payment_succeeded→Entitlements::assignPlan()or renew logic you own.customer.subscription.deleted→ schedulechangePlan()to a free tier or let licenses expire.PlanAssigned,LicenseConsumed,LicenseReleased→ audit logs, usage billing, analytics.
Keep webhooks thin.
Entitlement mutations belong in queued jobs with idempotency keys — Stripe will retry; your assign path must tolerate duplicates.
Optional Filament v5 plugin ships plan/category CRUD and a LicensesRelationManager for subscriber resources. Skip it if your admin is bespoke; the facade API doesn't depend on Filament.
Ops you will actually run
Reconciliation — after manual DB surgery or a bug, Entitlements::recalculate($workspace) recomputes slot_used from open usages. Per-license reconcile() exists for surgical fixes.
Scheduler — deferred transitions won't apply themselves. Pick a cadence; shorter intervals mean less lag between ends_at and the downgrade taking effect.
Failed transitions — if validation fails at apply time, the transition flips to failed with a reason; siblings still process. Monitor that status or you'll wonder why a downgrade never landed.
Subscription active is a billing answer.
Seat available is an entitlement answer.