Feature Access
Product feature entitlement gates for plans, subscriptions, cohorts, regions, beta programs, and tenant capabilities.
Feature Access
Feature access answers: "Is this identity entitled to use this product feature?"
It is separate from authorization:
| Layer | Question |
|---|---|
| Authentication | Who is the caller? |
| Authorization | Can this caller perform this action on this resource? |
| Feature access | Does this caller's product entitlement include this feature? |
| Quota | Does this caller still have allowance left? |
| Rate limit | Is this caller sending requests too quickly? |
Feature access is commonly subscription-driven, but it is not subscription-specific. You can use it for beta flags, tenant modules, regional availability, staff-only tools, enterprise add-ons, or partner entitlements.
Compile-Time Map
#[RequiresFeatureAccess] attributes are scanned at compile time. Runtime enforcement uses a prebuilt route map and policy services.
Step-by-Step
Generate a policy:
php vortos make:feature-policy Subscription -c BillingImplement entitlement rules:
namespace App\Billing\Application\Policy;
use Vortos\Auth\Contract\UserIdentityInterface;
use Vortos\Auth\FeatureAccess\Contract\FeatureAccessPolicyInterface;
final class SubscriptionFeaturePolicy implements FeatureAccessPolicyInterface
{
private const PLAN_FEATURES = [
'free' => ['checkout.basic'],
'pro' => ['checkout.basic', 'checkout.bulk', 'exports.csv'],
'enterprise' => ['checkout.basic', 'checkout.bulk', 'exports.csv', 'sso', 'audit.export'],
];
public function canAccess(UserIdentityInterface $identity, string $feature): bool
{
$plan = $identity->getAttribute('plan', 'free');
return in_array($feature, self::PLAN_FEATURES[$plan] ?? [], true);
}
}Gate a controller:
use Vortos\Auth\FeatureAccess\Attribute\RequiresFeatureAccess;
#[RequiresFeatureAccess('checkout.bulk')]
final class BulkCheckoutController {}Type-Safe Feature Names
Prefer enums for large products:
enum Feature: string
{
case BulkCheckout = 'checkout.bulk';
case CsvExport = 'exports.csv';
case Sso = 'sso';
}
#[RequiresFeatureAccess(Feature::BulkCheckout)]
final class BulkCheckoutController {}Payment Required
Use paymentRequired: true when the correct remediation is billing or upgrade.
#[RequiresFeatureAccess('checkout.bulk', paymentRequired: true)]
final class BulkCheckoutController {}Without paymentRequired, Vortos returns 403. With it, Vortos returns 402.
Responses
Forbidden:
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json{
"type": "https://docs.vortos.dev/errors/feature-access-denied",
"title": "Forbidden",
"status": 403,
"detail": "Your plan does not include access to this feature.",
"instance": "/api/v1/checkout/bulk",
"extensions": {
"feature": "checkout.bulk",
"payment_required": false
}
}Payment required:
HTTP/1.1 402 Payment Required
Content-Type: application/problem+json{
"type": "https://docs.vortos.dev/errors/payment-required",
"title": "Payment Required",
"status": 402,
"detail": "This feature requires an active subscription.",
"instance": "/api/v1/checkout/bulk",
"extensions": {
"feature": "checkout.bulk",
"payment_required": true
}
}Method-Level Feature Gates
#[AsController]
#[Route('/exports')]
final class ExportController
{
#[Route('/csv', methods: ['GET'])]
#[RequiresFeatureAccess(Feature::CsvExport)]
public function csv(): Response {}
#[Route('/audit', methods: ['GET'])]
#[RequiresFeatureAccess(Feature::AuditExport, paymentRequired: true)]
public function audit(): Response {}
}Multiple Feature Gates
All gates must pass:
#[RequiresFeatureAccess(Feature::AdvancedAnalytics)]
#[RequiresFeatureAccess(Feature::CustomRoles)]
final class EnterpriseReportController {}Middleware Order
priority 7: RateLimit IP + Global
priority 6: Auth
priority 5: Two-Factor
priority 4: RateLimit User
priority 3: Authorization
priority 2: Ownership
priority 1: Feature Access
priority 0: QuotaFeature access runs after permission and ownership checks, before quota consumption. A request that is not entitled to a feature does not consume quota.
Observability
Metrics:
feature_access_allowed_total{feature,policy,controller}
feature_access_denied_total{feature,policy,controller}Denied requests are logged to the security channel and traced when tracing is configured.
Security and Performance
- Keep feature names low-cardinality.
- Prefer enums for product-scale feature catalogs.
- Do not query unbounded external services in
canAccess. - Prefer identity claims or cached subscription reads.
- Keep denial logs free of sensitive customer data.
Troubleshooting
Feature gate does not run
Confirm the controller is registered as a controller service and the route resolves to that controller class.
Every feature is denied
Check your policy default. Most policies should explicitly allow known features and deny unknown features:
default => false403 returned when frontend expects upgrade flow
Set:
#[RequiresFeatureAccess('feature.name', paymentRequired: true)]Feature access feels like authorization
If the rule is about resource action permission, use authorization policies. If the rule is about product packaging or entitlement, use feature access.
Rate Limiting
Per-user, per-IP, and global request throttling with compile-time validation, Redis counters, headers, Problem Details responses, and observability.
Usage Quotas
Business usage limits with typed quota subject resolvers, atomic Redis enforcement, Problem Details responses, and quota-specific headers.