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
- illuminate/container: ^11.0
- illuminate/database: ^11.0
- illuminate/queue: ^11.0
- illuminate/support: ^11.0
Requires (Dev)
- orchestra/testbench: ^9.0
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-07-04 18:07:21 UTC
README
A feature/entitlement engine for Laravel 11+. 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 — Redis-backed resolution cache with configurable TTL
- Fluent 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
Configuration
See config/entitlements.php for all options:
| Key | Default | Description |
|---|---|---|
cache.store |
redis |
Cache store for entitlement decisions |
cache.ttl |
3600 |
Cache TTL in seconds |
cache.prefix |
ent: |
Cache key prefix |
usage.queue |
default |
Queue for usage aggregation jobs |
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 ]);
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 $user->why('sso'); // Explanation // Via facade Entitlement::for($user)->can('sso'); Entitlement::for($user)->why('sso'); // Via helper entitlement($user)->can('sso');
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.
Config
A generic configuration value (always allowed, returns the raw value).
$decision = entitlement($user)->resolve('max_upload_size'); // Decision with 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
Behind the scenes, use() creates a UsageRecord row and dispatches a queue job (AggregateUsageJob) to update the UsageSummary for the current period.
Usage aggregation is queued by default. Configure the queue name in config/entitlements.php.
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 uses a database transaction with row-level locking (SELECT ... FOR UPDATE) to prevent double-counting when two requests consume usage simultaneously.
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 |
why(string $key) |
Explanation |
Get full resolution trace (debugging) |
flushCache(?string $key = null) |
void |
Clear cached decisions |
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->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 php artisan entitlement:check sso "User:1" # 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).
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
phpunit
The test suite uses SQLite in-memory database.
Changelog
See the GitHub releases for version history.