laravel-entitlements maintained by saadmajeed
Laravel Entitlements
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.