saadmajeed/laravel-entitlements

SaaS feature engine — decisions about access, limits, and usage for Laravel.

Maintainers

Package info

github.com/SaadMajeed565/laravel-entitlements

pkg:composer/saadmajeed/laravel-entitlements

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.0 2026-08-13 12:02 UTC

This package is auto-updated.

Last update: 2026-08-13 12:57:16 UTC


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_STORE for 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_fields in config/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 custom conditional.attribute_resolver (a class implementing SaadMajeed\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:

  1. Per-subject override (if any)
  2. Expiry (for time-based)
  3. Conditional rules
  4. Subject's direct plan value
  5. Parent plan chain (walk up)
  6. 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 (OverrideApplied event)
  • A subject's plan changes (PlanChanged event)
  • Usage is recorded (UsageRecorded event)

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:

  1. Acquires a per-entitlement named advisory lock (serializing concurrent consumers on MySQL/PostgreSQL),
  2. Inserts the UsageRecord row,
  3. Reads and increments the UsageSummary for the current period inside a SELECT ... FOR UPDATE row lock,
  4. Checks the limit (hard limit rejects with LimitExceededException; soft limit records but fires LimitReached),
  5. Commits — and only then fires the LimitReached event 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.