Plan Entitlements & Feature Gating
v1.2
Plan entitlements are machine-readable capabilities attached to a plan. They let you enforce feature limits (e.g. "100 API calls per month", "up to 5 team members") based on which plan a user is subscribed to — without hardcoding limits in your module code.
How entitlements work
Each PlanEntitlement row represents one capability on one plan:
| Column | Description |
|---|---|
plan_id | The plan this entitlement belongs to |
key | Machine-readable identifier (e.g. api_calls_per_month) |
value | The limit or flag (see conventions below) |
label | Human-readable text shown on the public pricing page |
is_visible | Whether to show this on the pricing page |
Value conventions
| Stored value | Meaning | Helper |
|---|---|---|
NULL | Unlimited / feature is on with no cap | $e->isUnlimited() → true |
'100' | Numeric cap of 100 | $e->numericLimit() → 100 |
'true' | Boolean feature — enabled | $e->boolValue() → true |
'false' | Boolean feature — disabled | $e->boolValue() → false |
Seeding entitlements for your module
Seed entitlements when your module activates. Do this in a setup seeder triggered by the ModuleActivated event:
use App\Models\Plan;
use App\Models\PlanEntitlement;
use App\Models\Product;
// Get all plans for your product
$plans = Plan::where('product_id', $yourProductId)->get();
foreach ($plans as $plan) {
PlanEntitlement::firstOrCreate(
['plan_id' => $plan->id, 'key' => 'api_calls_per_month'],
[
'value' => $plan->name === 'Enterprise' ? null : '100',
'label' => $plan->name === 'Enterprise' ? 'Unlimited API calls' : '100 API calls / month',
'is_visible' => true,
]
);
}Use firstOrCreate so re-running the seeder doesn't overwrite admin edits.
Reading entitlements in code
Check a single key
use App\Models\PlanEntitlement;
$value = PlanEntitlement::valueOf($plan, 'api_calls_per_month');
// Returns the raw string value, or null if unlimited / key doesn't exist.Enforce a numeric limit
$entitlement = PlanEntitlement::where('plan_id', $plan->id)
->where('key', 'api_calls_per_month')
->first();
if ($entitlement && ! $entitlement->isUnlimited()) {
$limit = $entitlement->numericLimit(); // e.g. 100
$current = $user->apiCallsThisMonth();
if ($current >= $limit) {
abort(429, 'Monthly API call limit reached.');
}
}Check a boolean feature flag
$entitlement = PlanEntitlement::where('plan_id', $plan->id)
->where('key', 'priority_support')
->first();
$hasPrioritySupport = $entitlement?->boolValue() ?? false;Gate routes with middleware
Each module is responsible for registering its own middleware alias in its ModuleServiceProvider::boot() and applying it to its routes. See the AiAgentsCore module for a real example — it registers agent.access and applies it to all agent routes.
The pattern:
// In ModuleServiceProvider::boot()
$this->app['router']->aliasMiddleware('my-module.auth', MyModuleAuthMiddleware::class);// In routes/web.php
Route::middleware(['web', 'auth', 'my-module.auth'])
->group(function () {
Route::get('/account/my-feature', MyController::class);
});Inside the middleware, resolve the entitlement:
$entitlement = PlanEntitlement::where('plan_id', $plan->id)
->where('key', 'my_feature')
->first();
if (! $entitlement || $entitlement->boolValue() === false) {
abort(403, 'Your plan does not include this feature.');
}Displaying entitlements on pricing pages
ProductService::preparePricingData() automatically includes visible entitlements in the plan data passed to pricing page views. Each plan in $pricingData['plans'] has a features array populated from PlanEntitlement rows where is_visible = true.
No extra work needed — just seed the entitlements with is_visible = true and they appear on the pricing page.
Admin editing
Admins can edit entitlement values through the Product Manager (Admin → Products → Edit → Plans → Plan modal → Features tab). Changes take effect immediately with no deployment required.