Vortos
Authentication

Usage Quotas

Business usage limits with typed quota subject resolvers, atomic Redis enforcement, Problem Details responses, and quota-specific headers.

Usage Quotas

Quotas enforce business allowances: monthly exports, AI completions, checkout orders, storage uploads, seats, or project creation.

They are not traffic throttles. Use rate limiting for "100 requests per minute" and quotas for "10,000 checkout orders per month".

Enterprise Contract

Quota enforcement is typed, compile-time validated, Redis-backed, atomic, observable, and designed for FrankenPHP worker mode. Runtime enforcement does not reflect attributes.

Concepts

ConceptPurpose
Quota nameProduct limit name, for example checkout.orders
CostHow many units this request consumes
PeriodHourly, Daily, Monthly, or Total
ResolverDefines the quota bucket subject: user, global, organization, project, workspace
BucketLow-cardinality resolver name used in Redis keys, headers, logs, metrics, and traces
PolicyReturns the limit for a quota name and bucket

Vortos ships only generic resolvers:

Vortos\Auth\Quota\Resolver\UserQuotaResolver
Vortos\Auth\Quota\Resolver\GlobalQuotaResolver

Application concepts such as organization, team, workspace, tenant, and project belong to your app. Define them as custom resolvers.

Step-by-Step: Per-User Monthly Quota

Generate a quota policy:

php vortos make:quota-policy Billing -c Billing

Define the limit:

src/Billing/Application/Policy/BillingQuotaPolicy.php
namespace App\Billing\Application\Policy;

use Vortos\Auth\Contract\UserIdentityInterface;
use Vortos\Auth\Quota\Contract\QuotaPolicyInterface;
use Vortos\Auth\Quota\QuotaPeriod;
use Vortos\Auth\Quota\QuotaRule;

final class BillingQuotaPolicy implements QuotaPolicyInterface
{
    public function getQuota(UserIdentityInterface $identity, string $quota, string $bucket): QuotaRule
    {
        $plan = $identity->getAttribute('plan', 'free');

        if ($quota !== 'exports.csv' || $bucket !== 'user') {
            return QuotaRule::unlimited();
        }

        return match ($plan) {
            'enterprise' => QuotaRule::unlimited(),
            'pro' => new QuotaRule(1000, QuotaPeriod::Monthly),
            default => new QuotaRule(100, QuotaPeriod::Monthly),
        };
    }
}

Apply the quota:

use Vortos\Auth\Quota\Attribute\RequiresQuota;

#[RequiresQuota('exports.csv')]
final class ExportCsvController
{
    public function __invoke(): Response
    {
        // consumes 1 exports.csv unit from the current user's monthly bucket
    }
}

Step-by-Step: Organization Quota

Generate a resolver:

php vortos make:quota-resolver Organization -c Billing --bucket=organization

Generated shape:

src/Billing/Infrastructure/Quota/OrganizationQuotaResolver.php
namespace App\Billing\Infrastructure\Quota;

use Vortos\Auth\Contract\UserIdentityInterface;
use Vortos\Auth\Quota\Contract\QuotaSubjectResolverInterface;

final class OrganizationQuotaResolver implements QuotaSubjectResolverInterface
{
    public function bucket(): string
    {
        return 'organization';
    }

    public function resolve(UserIdentityInterface $identity): ?string
    {
        $subjectId = $identity->getAttribute('organization_id');

        return is_scalar($subjectId) && (string) $subjectId !== ''
            ? (string) $subjectId
            : null;
    }
}

Use it on a controller:

use App\Billing\Infrastructure\Quota\OrganizationQuotaResolver;
use Vortos\Auth\Quota\Attribute\RequiresQuota;

#[RequiresQuota('checkout.orders', by: OrganizationQuotaResolver::class)]
final class CreateOrderController
{
    public function __invoke(): Response
    {
        // consumes from quota:organization:{organization_id}:checkout.orders:{period}
    }
}

Define organization-specific limits:

public function getQuota(UserIdentityInterface $identity, string $quota, string $bucket): QuotaRule
{
    $plan = $identity->getAttribute('plan', 'free');

    if ($quota !== 'checkout.orders' || $bucket !== 'organization') {
        return QuotaRule::unlimited();
    }

    return match ($plan) {
        'enterprise' => new QuotaRule(100000, QuotaPeriod::Monthly),
        'pro' => new QuotaRule(10000, QuotaPeriod::Monthly),
        default => new QuotaRule(1000, QuotaPeriod::Monthly),
    };
}

Stacking Quotas

All quota attributes must pass. Each passing rule is consumed.

#[RequiresQuota('checkout.orders', by: OrganizationQuotaResolver::class)]
#[RequiresQuota('checkout.orders', cost: 1)] // default user bucket
final class CreateOrderController {}

This enforces both:

organization monthly checkout order allowance
user monthly checkout order allowance

Periods

PeriodMeaningReset
QuotaPeriod::HourlyBusiness usage per UTC hourNext UTC hour
QuotaPeriod::DailyBusiness usage per UTC dayNext UTC midnight
QuotaPeriod::MonthlyBusiness usage per UTC monthFirst day of next UTC month
QuotaPeriod::TotalLifetime usageNever

Vortos intentionally does not ship minute or second quota periods. Use rate limits for short windows:

new RateLimitRule(limit: 100, windowSeconds: 60)

Atomic Enforcement

Quota enforcement uses a Redis Lua script to perform check-and-consume atomically:

read current usage
check current + cost <= limit
increment only if allowed
set TTL on first write
return allowed/current/remaining/reset_at

The store uses Redis TIME as the clock source for reset-window calculation. This avoids app-server clock drift in distributed deployments and long-running FrankenPHP workers.

Response Headers

Quota-protected responses include quota-specific headers when enabled:

X-Quota-Name: checkout.orders
X-Quota-Limit: 10000
X-Quota-Remaining: 7341
X-Quota-Reset: 1717200000

X-Quota-Reset is always a Unix timestamp. Vortos does not reuse X-RateLimit-* for business quotas.

Quota Exceeded Response

Quota exceeded returns 403 because it is a business entitlement failure, not traffic throttling.

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
X-Quota-Name: checkout.orders
X-Quota-Limit: 10000
X-Quota-Remaining: 0
X-Quota-Reset: 1717200000
{
  "type": "https://docs.vortos.dev/errors/quota-exceeded",
  "title": "Quota Exceeded",
  "status": 403,
  "detail": "You have exceeded your monthly limit of 10000 checkout.orders.",
  "instance": "/api/v1/orders",
  "extensions": {
    "quota_name": "checkout.orders",
    "bucket": "organization",
    "limit": 10000,
    "remaining": 0,
    "reset_at": 1717200000
  }
}

Configuration

config/auth.php
use Vortos\Auth\Quota\QuotaFailureMode;

return static function (VortosAuthConfig $config): void {
    $config
        ->quotaFailureMode(QuotaFailureMode::FailClosed)
        ->quotaHeaders(true)
        ->problemDetails(true);
};
OptionDefaultMeaning
quotaFailureMode(FailClosed)yesRedis outage blocks quota-protected requests
quotaFailureMode(FailOpen)noRedis outage allows requests
quotaHeaders(true)yesEmits X-Quota-* headers
problemDetails(true)yesEmits application/problem+json

Use fail-closed for billing/cost-sensitive operations. Use fail-open only when availability is more important than strict enforcement.

Observability

Quota enforcement emits low-cardinality metrics when the metrics package is enabled:

quota_allowed_total{quota,bucket,period,controller}
quota_blocked_total{quota,bucket,period,controller}
quota_consumed_total{quota,bucket,period,controller}

It logs denies, unresolved subjects, and Redis store failures to the security channel. It adds trace events on denial/store-failure paths when tracing is configured.

Success paths avoid extra logs and trace spans to keep FrankenPHP worker throughput predictable.

Framework metrics use low-cardinality labels only. Resolver bucket names such as user, organization, or workspace are safe labels. Raw subject IDs such as user_123, org_456, emails, and request IDs are not safe metric labels.

Security Rules

  • Resolver classes are validated at container compile time.
  • Resolvers must implement QuotaSubjectResolverInterface.
  • Resolver buckets must match [a-z0-9._-]+.
  • Resolver subjects must be non-empty.
  • Missing resolver, invalid resolver, or unresolved subject fails closed.
  • Redis keys sanitize unsafe subject values.
  • Do not build resolver classes from request input.
  • Prefer identity claims over database lookups in resolvers.

Troubleshooting

Quota attribute has no effect

Check:

php vortos list --raw | grep quota

Then verify Redis-backed auth is active. Quota middleware is registered only when Redis is available.

Controller always returns quota subject not resolved

Your resolver returned null. Check the identity contains the claim:

$identity->getAttribute('organization_id')

For organization quotas, the token or identity provider must include the organization ID.

Container compilation fails for resolver

The class in by: must exist, implement QuotaSubjectResolverInterface, and be registered as a service.

#[RequiresQuota('checkout.orders', by: OrganizationQuotaResolver::class)]

Redis restarted and counters reset

Redis-backed quota counters are enforcement counters. If Redis is restarted without persistence, current counters may reset. For billing-grade usage, record durable usage events in your write model and use Redis for fast request-time enforcement.

Quota exceeded returns 403, not 429

This is intentional. 429 is for rate limits. Quotas are product/business allowances and return 403.

On this page