masterix21 / laravel-entitlements
Simple and flexible entitlement management for Laravel applications, with support for plans, features, limits, and usage tracking.
Package info
github.com/masterix21/laravel-entitlements
pkg:composer/masterix21/laravel-entitlements
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/contracts: ^11.0||^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
- spatie/laravel-translatable: ^6.0
Requires (Dev)
- larastan/larastan: ^2.9||^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^7.10||^8.0
- orchestra/testbench: ^9.0.0||^10.0.0||^11.0.0
- pestphp/pest: ^3.0||^4.0
- pestphp/pest-plugin-arch: ^3.0||^4.0
- pestphp/pest-plugin-laravel: ^3.0||^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
- awcodes/badgeable-column: ^3.0 — Used by the included LicensesRelationManager and PlansTable for badge columns
- codewithdennis/filament-lucide-icons: ^1.0 — Used by the included Resources for navigation icons
- filament/filament: ^5.0 — Required to use the EntitlementsPlugin with the Filament admin panel
README
A flexible entitlement management system for Laravel applications. Define subscription plans, issue licenses to any model, and track consumption — slot-based or pool-based — with project-specific entitlement types injected via configuration.
Why
Every SaaS reinvents the same wheel: plans, plan items, licenses with start/end dates, usage tracking with two-phase release for some resources (a device that must confirm deactivation) and metered drain for others (a token pool). This package extracts that machinery so each project only declares the things that actually change: which entitlement types exist and how each one is consumed.
Features
- Polymorphic ownership — any model with the
HasEntitlementstrait can hold licenses (workspace, team, user, tenant) - Plans catalog — categorized plans with billing period (monthly/yearly), recurring or fixed-term, with translatable names
- Plan items — define how many slots of each type a plan grants; flexible items accept per-assignment overrides
- Two consumption strategies out of the box:
SlotStrategy— one usage per subject, with optional two-phase release (Active → Releasing → Released)PoolStrategy— drainable counter across multiple licenses, FIFO by expiration
- Project-specific type enum — declare your own backed enum (e.g.
Device,AiTokens,Seat,ApiCall) and map each case to a strategy - Domain events —
PlanAssigned,LicenseConsumed,ReleaseRequested,LicenseReleased,LicenseReconciled - Reconciliation — recompute
slot_usedfrom actual open usages, useful after manual intervention or drift - Optional Filament v5 admin UI — plug-in for Plans/Plan Categories management and a
LicensesRelationManagerfor the subscriber resource
Requirements
- PHP
^8.2 - Laravel
^11 || ^12 || ^13 spatie/laravel-package-toolsspatie/laravel-translatable(for translatable plan names)
Installation
composer require masterix21/laravel-entitlements
Publish the config and migrations:
php artisan vendor:publish --tag="laravel-entitlements-config" php artisan vendor:publish --tag="laravel-entitlements-migrations" php artisan migrate
Configuration
config/entitlements.php after publishing:
return [ // Required: the backed enum that implements EntitlementType 'type_enum' => \App\Enums\LicenseType::class, // Override models if you want to extend them 'models' => [ 'plan_category' => \LucaLongo\LaravelEntitlements\Models\PlanCategory::class, 'plan' => \LucaLongo\LaravelEntitlements\Models\Plan::class, 'plan_item' => \LucaLongo\LaravelEntitlements\Models\PlanItem::class, 'license' => \LucaLongo\LaravelEntitlements\Models\License::class, 'license_usage' => \LucaLongo\LaravelEntitlements\Models\LicenseUsage::class, ], 'table_names' => [ 'plan_categories' => 'entitlement_plan_categories', 'plans' => 'entitlement_plans', 'plan_items' => 'entitlement_plan_items', 'licenses' => 'entitlement_licenses', 'license_usages' => 'entitlement_license_usages', ], ];
The type_enum is validated at boot: if the class doesn't exist or doesn't implement EntitlementType, an InvalidEntitlementTypeException is thrown.
Usage
1. Declare your entitlement types
Create a backed enum that implements EntitlementType and maps each case to a strategy:
<?php namespace App\Enums; use LucaLongo\LaravelEntitlements\Contracts\EntitlementStrategy; use LucaLongo\LaravelEntitlements\Contracts\EntitlementType; use LucaLongo\LaravelEntitlements\Strategies\PoolStrategy; use LucaLongo\LaravelEntitlements\Strategies\SlotStrategy; 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(), }; } }
Reference it in config/entitlements.php:
'type_enum' => \App\Enums\LicenseType::class,
2. Add the trait to the model that owns licenses
use Illuminate\Database\Eloquent\Model; use LucaLongo\LaravelEntitlements\Concerns\HasEntitlements; class Workspace extends Model { use HasEntitlements; }
The trait adds a licenses() morphMany relationship.
3. Create plans
use LucaLongo\LaravelEntitlements\Enums\BillingPeriod; use LucaLongo\LaravelEntitlements\Models\Plan; use LucaLongo\LaravelEntitlements\Models\PlanCategory; $category = PlanCategory::create(['name' => ['en' => 'Business']]); $plan = Plan::create([ 'plan_category_id' => $category->id, 'name' => ['en' => 'Pro Monthly'], 'billing_period' => BillingPeriod::Monthly, 'is_recurring' => true, 'is_active' => true, ]); $plan->items()->createMany([ ['type' => LicenseType::Device->value, 'quantity' => 5, 'is_flexible' => false], ['type' => LicenseType::AiTokens->value, 'quantity' => 100000, 'is_flexible' => true], ['type' => LicenseType::Seat->value, 'quantity' => 10, 'is_flexible' => false], ]);
4. Assign a plan to a subscriber
use LucaLongo\LaravelEntitlements\Facades\Entitlements; $licenses = Entitlements::assignPlan( subscriber: $workspace, plan: $plan, startsAt: now(), quantityOverrides: [ // Only flexible items accept overrides; keyed by PlanItem id $plan->items->firstWhere('is_flexible', true)->id => 500000, ], );
Recurring plans produce licenses with ends_at = null. Fixed-term plans compute ends_at via BillingPeriod::advance($startsAt).
assignPlan() also threads a parent_id: the first license created becomes the anchor (parent_id = null), every subsequent license for the same call is linked to it. This is what lets the Filament UI render each plan assignment as a single row that aggregates all its resources.
5. Consume entitlements
// Slot-based: one usage per subject $usage = Entitlements::consume($workspace, LicenseType::Device, $device); // Pool-based: drains the configured amount across one or more valid licenses $usage = Entitlements::consume( $workspace, LicenseType::AiTokens, $aiUsage, amount: 1500, );
If capacity is insufficient, NoEntitlementAvailableException is thrown.
6. Release entitlements
// For two-phase SlotStrategy: request release (status -> Releasing), emits ReleaseRequested event Entitlements::requestRelease($usage); // When the external action completes (e.g. device confirms deactivation): Entitlements::confirmRelease($usage); // Force release from any state (admin override): Entitlements::forceRelease($usage);
For single-phase strategies (SlotStrategy(twoPhase: false) or PoolStrategy) requestRelease and confirmRelease both release immediately.
7. Query availability
Entitlements::available($workspace, LicenseType::AiTokens); // sum of remaining across valid licenses Entitlements::capacity($workspace, LicenseType::AiTokens); // sum of slot_total across valid licenses Entitlements::can($workspace, LicenseType::AiTokens, 1500); // bool
8. Reconcile drifted counters
// Recompute slot_used from open usages for a single license Entitlements::reconcile($license); // Reconcile every license owned by the subscriber $result = Entitlements::recalculate($workspace); // ['reconciled' => 7]
Domain model
PlanCategory ──< Plan ──< PlanItem
│
└──< License (polymorphic subscriber) ──< LicenseUsage (polymorphic subject)
| Model | Key columns |
|---|---|
PlanCategory |
name (translatable), sort |
Plan |
plan_category_id, name (translatable), billing_period, is_recurring, is_active |
PlanItem |
plan_id, type, quantity, is_flexible |
License |
subscriber_* (morph), plan_id, parent_id, type, slot_total, slot_used, starts_at, ends_at |
LicenseUsage |
license_id, subject_* (morph), amount, status |
License exposes scopes valid() and ofType(EntitlementType $type), plus a remaining accessor (slot_total - slot_used, floored at 0).
LicenseUsage exposes scope open() (not Released) and casts status to LicenseUsageStatus.
Strategies
SlotStrategy
new SlotStrategy(twoPhase: false) // default new SlotStrategy(twoPhase: true)
consume()locks the oldest-expiring valid license with available capacity, creates a usage row withamount = 1, incrementsslot_used.requestRelease():- single-phase: sets status to
Released, decrementsslot_used, firesLicenseReleased - two-phase: sets status to
Releasing, firesReleaseRequested(you typically dispatch an external job here)
- single-phase: sets status to
confirmRelease()(two-phase): sets status toReleased, decrementsslot_used, firesLicenseReleasedforceRelease()releases from any state (admin override).
PoolStrategy
consume(amount: N)locks all valid licenses with capacity (ordered by expiration ascending, perpetual last), validates total availability, drains N across multiple licenses creating one usage row per license.- All release methods are equivalent: they set the usage to
Releasedand decrement the source licenseslot_usedby the usageamount. supportsTwoPhaseRelease()returnsfalse.
Custom strategies
Implement the EntitlementStrategy contract:
namespace LucaLongo\LaravelEntitlements\Contracts; interface EntitlementStrategy { public function consume(Model $subscriber, EntitlementType $type, Model $subject, int $amount = 1): LicenseUsage; public function requestRelease(LicenseUsage $usage): void; public function confirmRelease(LicenseUsage $usage): void; public function forceRelease(LicenseUsage $usage): void; public function supportsTwoPhaseRelease(): bool; }
Then return it from your enum's strategy() method.
Events
| Event | Payload | Fired when |
|---|---|---|
PlanAssigned |
Model $subscriber, Plan $plan, Collection $licenses |
After assignPlan() creates the licenses |
LicenseConsumed |
LicenseUsage $usage |
After a strategy creates an active usage row |
ReleaseRequested |
LicenseUsage $usage |
Two-phase release: status transitioned to Releasing |
LicenseReleased |
LicenseUsage $usage |
Final release: status transitioned to Released |
LicenseReconciled |
License $license |
After reconcile() recomputes the counter |
Hook your domain logic via standard Laravel listeners. The two-phase release flow is typically wired as: ReleaseRequested → dispatch external job → on completion call confirmRelease()`.
Exceptions
NoEntitlementAvailableException— thrown by strategies when capacity is insufficientInvalidEntitlementTypeException— thrown at boot ifconfig('entitlements.type_enum')doesn't reference a valid backed enum implementingEntitlementType
Filament integration (optional)
The package ships a Filament v5 plugin that exposes a Plans/Plan Categories admin UI and a LicensesRelationManager you can attach to your subscriber resource.
Install Filament v5 plus the two optional UI dependencies the resources rely on:
composer require filament/filament:^5.0 awcodes/filament-badgeable-column codewithdennis/filament-lucide-icons
Register the plugin on your panel:
use LucaLongo\LaravelEntitlements\Filament\EntitlementsPlugin; public function panel(Panel $panel): Panel { return $panel->plugin(EntitlementsPlugin::make()); }
Opt out of either resource if you want to provide your own:
EntitlementsPlugin::make() ->withoutPlanResource() ->withoutPlanCategoryResource();
Attach the LicensesRelationManager to the resource of your subscriber model (e.g. WorkspaceResource):
use LucaLongo\LaravelEntitlements\Filament\RelationManagers\LicensesRelationManager; public static function getRelations(): array { return [LicensesRelationManager::class]; }
The relation manager provides these actions out of the box:
- Assign Plan — pick an active plan, set start/end dates, edit the quantity of every flexible item (defaults are pre-filled from the plan when you select it). Licenses created in the same assignment are grouped via
parent_idso the table shows one row per assignment. - Edit Plan — same layout as Assign Plan with the plan shown read-only at the top. Lets you adjust
starts_at,ends_at(propagated to anchor + children) and the quantity of every license in the group. - Recalculate Usages — reconcile every license owned by the subscriber.
- Force Release Slot — admin override for usages stuck in
Releasing(two-phase strategies).
By default Plan Categories appears nested under "Subscription Plans" in the navigation sidebar (getNavigationParentItem() on PlanCategoryResource matches getNavigationLabel() on PlanResource).
Translating entitlement type labels
The Filament UI labels enum cases in two ways:
- If your
type_enumcases implement agetLabel(): stringmethod (the standard FilamentHasLabelcontract), it is used as-is. - Otherwise the case
nameis passed through Laravel's__()helper, so you can translate it by adding the case name as a key in yourlang/{locale}.json(e.g."Device": "Dispositivo").
The placeholder :type quantity (used as the "Quantità X" label in the assign/edit form) is also translatable.
Translations
The package ships JSON translation files for English (en), Italian (it), Chinese (zh) and Russian (ru) covering every string used by the Filament UI. They are loaded automatically — no extra setup needed.
To customize the translations, publish them to your app's lang/ directory:
php artisan vendor:publish --tag="laravel-entitlements-translations"
You can then edit lang/it.json and lang/en.json and add other locales (e.g. lang/fr.json) using the English strings as keys.
Testing
composer test
The test suite runs against :memory: SQLite via Orchestra Testbench, with a workbench TestType enum that maps Single → SlotStrategy(twoPhase: true) and Pooled → PoolStrategy.
Static analysis
composer analyse
PHPStan level configured via phpstan.neon.dist. The src/Filament directory is excluded by default since Filament is not a dev dependency; install it locally if you want to lint the plugin too.
Code style
composer format
Runs Laravel Pint with the default preset.
Credits
- Luca Longo
- Domain logic extracted and generalized from the Totem in Cloud production codebase.
License
The MIT License (MIT). See License File for more information.