saadmajeed / laravel-entitlements
SaaS feature engine — decisions about access, limits, and usage for Laravel.
Package info
github.com/SaadMajeed565/laravel-entitlements
pkg:composer/saadmajeed/laravel-entitlements
Requires
- php: ^8.2
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/container: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.9
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^11.0
README
A feature/entitlement engine for Laravel 11, 12, and 13. Define plans, features, limits, metered usage, and conditional access rules — then check them anywhere in your app.
Features
- Boolean features — on/off feature flags per plan
- Numeric limits — caps like "max 5 projects"
- Metered usage — countable consumption with daily/monthly/yearly resets
- Time-based entitlements — features that expire after a date
- Conditional entitlements — access gated by subject attributes (role, region, etc.)
- Plan inheritance — child plans inherit values from parent plans
- Per-subject overrides — override plan values for individual users
- Audit logging — tracks changes to entitlements, plans, and overrides
- Caching — resolution cache with configurable TTL (defaults to the array store; set
ENTITLEMENTS_CACHE_STOREfor production) - Fluent definition API, Facade, Middleware, Blade directive
Installation
composer require saadmajeed/laravel-entitlements
Publish the config and migrations:
php artisan vendor:publish --tag=entitlements-config php artisan vendor:publish --tag=entitlements-migrations php artisan migrate
Or run everything in one step:
php artisan entitlement:install
Configuration
See config/entitlements.php for all options:
| Key | Default | Description |
|---|---|---|
cache.store |
array |
Cache store for entitlement decisions (use redis/database in production) |
cache.ttl |
3600 |
Cache TTL in seconds |
cache.prefix |
ent: |
Cache key prefix |
usage.enabled |
true |
Enable synchronous, transactional usage tracking |
audit.enabled |
true |
Enable audit logging |
middleware.default_http_code |
403 |
HTTP status when entitlement check fails |
default_subject_model |
null |
Default model for artisan command |
Quick Start
1. Add the trait to your subject
use SaadMajeed\Entitlements\Traits\HasPlan; class User extends Authenticatable { use HasPlan; }
The HasPlan trait adds a plan() relationship and convenience methods.
2. Create a plan
use SaadMajeed\Entitlements\Models\Plan; $plan = Plan::create(['name' => 'Pro', 'slug' => 'pro']);
Assign the plan to a user:
$user->plan()->associate($plan); $user->save();
3. Create entitlements
use SaadMajeed\Entitlements\Models\Entitlement; // Boolean feature flag Entitlement::create([ 'key' => 'sso', 'type' => 'boolean', 'default_value' => false, ]); // Numeric limit Entitlement::create([ 'key' => 'max_projects', 'type' => 'limit', 'default_value' => 3, ]); // Metered usage (resets monthly) Entitlement::create([ 'key' => 'api_calls', 'type' => 'metered', 'default_value' => 1000, 'reset_period' => 'monthly', ]); // Time-based feature Entitlement::create([ 'key' => 'early_access', 'type' => 'time', 'metadata' => ['expires_at' => '2025-12-31'], ]); // Conditional feature Entitlement::create([ 'key' => 'beta_feature', 'type' => 'conditional', 'metadata' => [ 'conditions' => [ ['field' => 'region', 'operator' => '=', 'value' => 'us'], ], ], ]);
4. Assign values to a plan
$plan->entitlements()->attach($entitlement->id, [ 'value' => ['enabled' => true], // boolean // or 'value' => 10, // numeric limit // or 'value' => ['max' => 50], // alternative limit syntax ]);
4b. Define a plan fluently (recommended)
The manual steps above are fine, but the fluent Entitlements facade hides the
pivot-format details and keeps your seeding idempotent:
use SaadMajeed\Entitlements\Facades\Entitlements; Entitlements::plan('base'); Entitlements::plan('pro') ->feature('sso', true) // boolean flag ->limit('max_projects', 50) // numeric cap ->meter('api_calls', 1000, 'monthly') // metered, resets monthly ->conditional('beta', [ // gated by subject attributes ['field' => 'role', 'operator' => '=', 'value' => 'beta'], ]) ->time('trial', '2025-12-31') // expires on a date ->parent('base'); // inherit from base
You can also pass a callback instead of chaining:
Entitlements::plan('pro', function ($plan) { $plan->feature('sso')->limit('max_projects', 50); });
Entitlements::plan() finds-or-creates the Plan and each referenced
Entitlement, then updates the per-plan pivot value on every run — so it is
safe to call from a seeder over and over. A full example plan tree lives in
database/seeders/EntitlementsSeeder.php.
Removing entitlements
To revoke an entitlement from a plan, detach it (the global Entitlement row
is kept so other plans can still use it):
Entitlements::plan('pro') ->remove('legacy_feature') // drop a single entitlement ->removeMany(['old_a', 'old_b']); // drop several at once // Make the plan authoritative — keep only these, detach everything else // (existing pivot values for the kept entitlements are preserved): Entitlements::plan('pro')->only(['sso', 'max_projects', 'api_calls']); // Clear plan inheritance: Entitlements::plan('pro')->withoutParent();
5. Check entitlements
// Via trait $user->can('sso'); // bool $user->limit('max_projects'); // ?float $user->remaining('api_calls'); // ?float $user->use('api_calls', 1); // Decision (record usage) $user->consume('api_calls', 1); // alias of use() $user->value('sso'); // resolved value (e.g. ['enabled' => true]) $user->why('sso'); // Explanation // Via facade Entitlement::for($user)->can('sso'); Entitlement::for($user)->consume('api_calls', 1); Entitlement::for($user)->why('sso'); // Via helper entitlement($user)->can('sso');
value() is a convenience getter for the resolved value of why($key)->value.
consume() is an alias of use() for recording metered/limit usage.
Entitlement Types
Boolean
Simple on/off feature flag. The pivot value should be ['enabled' => true] or ['enabled' => false].
$user->can('sso'); // true or false
Limit
A numeric cap. The value is a number or ['max' => N].
$user->limit('max_projects'); // e.g. 5 $user->remaining('max_projects'); // e.g. 3 (if 2 used)
Metered
Countable usage that resets on a period (daily, monthly, yearly).
$user->remaining('api_calls'); // e.g. 950 (of 1000) $user->use('api_calls', 1); // record usage, returns Decision $user->use('api_calls', 5); // record multiple at once
Soft limits
Set soft_limit: true in the entitlement's metadata to allow usage beyond the limit (fires event instead of throwing):
Entitlement::create([ 'key' => 'bonus_features', 'type' => 'metered', 'default_value' => 100, 'reset_period' => 'monthly', 'metadata' => ['soft_limit' => true], ]);
An LimitReached event is fired, but the use() call succeeds.
Time-based
A feature that expires at a specific date.
$user->can('early_access'); // false if expired, true otherwise
If expired, an ExpiredEntitlementException is thrown.
Conditional
Access depends on subject attributes.
// metadata: ['conditions' => [['field' => 'role', 'operator' => '=', 'value' => 'admin']]] $user->can('beta_feature'); // true only for admins
Supported operators: =, ==, ===, !=, !==, <>, >, >=, <, <=, in, not_in, contains.
Security: by default conditional rules may read any subject attribute. To restrict which attributes rules may reference, set
conditional.allowed_fieldsinconfig/entitlements.php. When the allowlist is non-empty, any rule referencing a field outside it causes that conditional entitlement to be denied ("deny unknown"). You can also provide a customconditional.attribute_resolver(a class implementingSaadMajeed\Entitlements\Contracts\AttributeResolver) to control how attribute values are read.
Config
A generic configuration value (always allowed, returns the raw value).
$explanation = entitlement($user)->why('max_upload_size'); $value = $explanation->value; // the raw configured value
Plan Inheritance
Plans can have a parent. Child plans inherit entitlement values from the parent unless overridden.
$base = Plan::create(['name' => 'Base', 'slug' => 'base']); $pro = Plan::create(['name' => 'Pro', 'slug' => 'pro', 'parent_id' => $base->id]); // Pro inherits all entitlements from Base, // plus any values explicitly set on Pro
The resolution pipeline checks:
- Per-subject override (if any)
- Expiry (for time-based)
- Conditional rules
- Subject's direct plan value
- Parent plan chain (walk up)
- Entitlement's default value
Overrides
Override an entitlement for a specific subject:
use SaadMajeed\Entitlements\Models\EntitlementOverride; EntitlementOverride::create([ 'subject_type' => $user->getMorphClass(), 'subject_id' => $user->getKey(), 'entitlement_id' => $entitlement->id, 'value' => ['enabled' => true], 'expires_at' => now()->addDays(7), 'reason' => 'Granted temporary access', 'performed_by_type' => $admin->getMorphClass(), 'performed_by_id' => $admin->getKey(), ]);
Overrides have the highest priority in the resolution pipeline. They can optionally expire.
Caching
Entitlement decisions are cached per subject per key. The cache is automatically invalidated when:
- An override is created, updated, or deleted (
OverrideAppliedevent) - A subject's plan changes (
PlanChangedevent) - Usage is recorded (
UsageRecordedevent)
Manually flush the cache:
Entitlement::for($user)->flushCache(); // all entitlements for user Entitlement::for($user)->flushCache('sso'); // specific key
Warm the cache:
php artisan entitlement:cache-warm
Events
| Event | Payload | Fired when |
|---|---|---|
EntitlementChecked |
subject, entitlement, allowed | After any entitlement check |
LimitReached |
subject, entitlement, used, limit, soft | When a limit is hit |
OverrideApplied |
override | Override created/updated/deleted |
PlanChanged |
subject | Subject's plan changes |
UsageRecorded |
subject, entitlement, amount | Usage recorded |
Listeners are auto-registered to invalidate the cache on OverrideApplied, PlanChanged, and UsageRecorded.
Usage Tracking
Usage is recorded synchronously and atomically — no queue, no background jobs. When you call use(), a single database transaction:
- Acquires a per-entitlement named advisory lock (serializing concurrent consumers on MySQL/PostgreSQL),
- Inserts the
UsageRecordrow, - Reads and increments the
UsageSummaryfor the current period inside aSELECT ... FOR UPDATErow lock, - Checks the limit (hard limit rejects with
LimitExceededException; soft limit records but firesLimitReached), - Commits — and only then fires the
LimitReachedevent outside the transaction.
Because the read-modify-write of the summary happens under a lock within one transaction, there is no double-counting and no lost update even under high concurrency.
For real-time accuracy, you can query remaining usage directly:
$decision = Entitlement::for($user)->use('api_calls', 1); $decision->remaining; // 999 (of 1000)
Concurrency
Usage recording is safe under concurrency on MySQL and PostgreSQL, where it uses a named advisory lock plus SELECT ... FOR UPDATE row locking, with automatic retry on deadlock (up to 3 attempts). SQLite is single-writer, so it is also safe but does not exercise the lock paths. The package ships with a parallel-consumer test (tests/Concurrency) that is run against real MySQL/PostgreSQL in CI.
API Reference
Entitlement::for($subject)
Returns a cloned EntitlementManager scoped to the subject.
| Method | Returns | Description |
|---|---|---|
can(string $key) |
bool |
Check if an entitlement is allowed |
canMany(array $keys) |
array<string, bool> |
Check multiple entitlements |
limit(string $key) |
?float |
Get the numeric limit value |
remaining(string $key) |
?float |
Get remaining usage |
use(string $key, float $amount = 1, array $metadata = []) |
Decision |
Record usage |
consume(string $key, float $amount = 1, array $metadata = []) |
Decision |
Alias of use() |
value(string $key) |
mixed |
Resolved value (why($key)->value) |
why(string $key) |
Explanation |
Get full resolution trace (debugging) |
flushCache(?string $key = null) |
void |
Clear cached decisions |
Entitlements facade (defining plans)
| Method | Returns | Description |
|---|---|---|
Entitlements::plan(string $slug, ?callable $callback = null) |
PlanBuilder |
Find-or-create a plan and define its entitlements fluently |
The returned PlanBuilder exposes feature(), limit(), meter(),
conditional(), time(), and parent() — all chainable and idempotent — plus
remove(), removeMany(), only() (keep only the given keys), and
withoutParent(), for revoking entitlements and clearing inheritance.
HasPlan trait methods
| Method | Returns | Description |
|---|---|---|
$subject->can(string $key) |
bool |
Delegates to Entitlement::for($subject)->can() |
$subject->limit(string $key) |
?float |
Delegates to Entitlement::for($subject)->limit() |
$subject->remaining(string $key) |
?float |
Delegates to Entitlement::for($subject)->remaining() |
$subject->use(string $key, float $amount = 1) |
Decision |
Delegates to Entitlement::for($subject)->use() |
$subject->consume(string $key, float $amount = 1) |
Decision |
Alias of use() |
$subject->value(string $key) |
mixed |
Resolved value |
$subject->why(string $key) |
Explanation |
Delegates to Entitlement::for($subject)->why() |
$subject->entitlement(string $key) |
FluentEntitlement |
Fluent API gateway |
Decision object
| Property | Type | Description |
|---|---|---|
allowed |
bool |
Whether access is granted |
key |
string |
The entitlement key |
value |
mixed |
The resolved value |
type |
string |
Entitlement type |
source |
?string |
Where the value came from (plan, override, default, etc.) |
limit |
?float |
The numeric limit (if applicable) |
used |
?float |
Current usage (if metered/limit) |
remaining |
?float |
Remaining usage (if metered/limit) |
overLimit |
bool |
Whether the limit is exceeded |
softLimit |
bool |
Whether a soft limit is active |
expiresAt |
?CarbonInterface |
Expiration date (if time-based) |
expired |
bool |
Whether the entitlement has expired |
path |
array |
Resolution steps taken |
Explanation object (from why())
A detailed trace of how an entitlement was resolved, including:
- The resolution steps and their results
- Plan info (id, name, slug)
- Override info (value, expires_at, reason)
- Expiry info (expires_at, expired)
- Usage info (used, limit, remaining)
Artisan Commands
# Check an entitlement for a subject (raw JSON explanation) php artisan entitlement:check sso "User:1" # Explain how an entitlement is resolved (human-readable trace) php artisan entitlement:explain sso "User:1" php artisan entitlement:explain sso "User:1" --json # Install: publish config + migrations, then migrate php artisan entitlement:install # List all entitlements php artisan entitlement:list # Show usage for a subject php artisan entitlement:usage "User:1" # Warm the entitlement cache php artisan entitlement:cache-warm
The subject argument accepts ModelClass:id syntax (e.g. User:1, App\Models\User:42).
entitlement:explain prints the same resolution trace as why() but in a
readable form — the final result (ALLOWED / DENIED), the source, plan, override,
expiry and usage details, and the ordered resolution steps. Pass --json to get
the raw Explanation as JSON (identical to entitlement:check).
Middleware
Protect routes with the entitlement middleware:
// In routes/web.php Route::get('/sso', function () { return view('sso'); })->middleware('entitlement:sso'); // With a redirect Route::get('/beta', function () { return view('beta'); })->middleware('entitlement:beta_feature:/upgrade');
If the subject (authenticated user) doesn't have the entitlement, the middleware aborts with 403 (or redirects if a redirect path is provided).
Blade Directive
@entitlement('sso') <p>You have SSO access.</p> @endentitlement
Requires the user to be authenticated.
Exceptions
| Exception | HTTP status | When thrown |
|---|---|---|
EntitlementNotFoundException |
n/a | Entitlement key not found in database |
ExpiredEntitlementException |
n/a | Time-based entitlement has expired |
LimitExceededException |
n/a | Hard limit exceeded (has key, limit, used, attempted) |
Extend your exception handler to map these to HTTP codes as needed:
// In bootstrap/app.php or ExceptionHandler ->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (LimitExceededException $e) { return response()->json(['error' => $e->getMessage()], 429); }); });
Testing
The suite runs against SQLite by default:
phpunit
Concurrency tests (parallel consumers, advisory/row locking) run against real MySQL / PostgreSQL and are isolated in their own process:
# against a configured DB_CONNECTION=mysql|pgsql phpunit -c phpunit.concurrency.xml phpunit # default suite (SQLite)
The CI matrix covers Laravel 11, 12, and 13 across MySQL 8.4 and PostgreSQL 16.