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: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-06-04 17:39 UTC

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:

  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

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.