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
| Concept | Purpose |
|---|---|
| Quota name | Product limit name, for example checkout.orders |
| Cost | How many units this request consumes |
| Period | Hourly, Daily, Monthly, or Total |
| Resolver | Defines the quota bucket subject: user, global, organization, project, workspace |
| Bucket | Low-cardinality resolver name used in Redis keys, headers, logs, metrics, and traces |
| Policy | Returns the limit for a quota name and bucket |
Vortos ships only generic resolvers:
Vortos\Auth\Quota\Resolver\UserQuotaResolver
Vortos\Auth\Quota\Resolver\GlobalQuotaResolverApplication 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 BillingDefine the limit:
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=organizationGenerated shape:
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 allowancePeriods
| Period | Meaning | Reset |
|---|---|---|
QuotaPeriod::Hourly | Business usage per UTC hour | Next UTC hour |
QuotaPeriod::Daily | Business usage per UTC day | Next UTC midnight |
QuotaPeriod::Monthly | Business usage per UTC month | First day of next UTC month |
QuotaPeriod::Total | Lifetime usage | Never |
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_atThe 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: 1717200000X-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
use Vortos\Auth\Quota\QuotaFailureMode;
return static function (VortosAuthConfig $config): void {
$config
->quotaFailureMode(QuotaFailureMode::FailClosed)
->quotaHeaders(true)
->problemDetails(true);
};| Option | Default | Meaning |
|---|---|---|
quotaFailureMode(FailClosed) | yes | Redis outage blocks quota-protected requests |
quotaFailureMode(FailOpen) | no | Redis outage allows requests |
quotaHeaders(true) | yes | Emits X-Quota-* headers |
problemDetails(true) | yes | Emits 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 quotaThen 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.