Vortos
Authentication

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:

LayerQuestion
AuthenticationWho is the caller?
AuthorizationCan this caller perform this action on this resource?
Feature accessDoes this caller's product entitlement include this feature?
QuotaDoes this caller still have allowance left?
Rate limitIs 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 Billing

Implement entitlement rules:

src/Billing/Application/Policy/SubscriptionFeaturePolicy.php
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: Quota

Feature 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 => false

403 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.

On this page