Vortos
Feature Flags

Targeting Rules

Percentage rollout, user whitelists, attribute-based rules, segments, deny rules, and rule evaluation order.

Targeting Rules

Rules determine who receives a flag. They are evaluated in insertion order — first match wins. If no rule matches, the flag returns false even if its status is enabled. A flag with status enabled and no rules returns true for everyone.

FlagEvaluator:
  1. Flag disabled?         → false (short-circuit, rules skipped)
  2. Deny rule matches?     → false (explicit block, takes priority)
  3. Walk allow rules in order:
       rule matches?        → true  (stop)
       rule does not match? → next rule
  4. No allow rule matched  → false

Rule types

Users whitelist

Specific user IDs always receive the flag. Use for internal QA, beta testers, or forcing a flag on for your own account.

php vortos vortos:flags:add-rule new-checkout \
  --type=users \
  --users=user-123,user-456
$flags->isEnabled('new-checkout', new FlagContext(userId: 'user-123')); // true
$flags->isEnabled('new-checkout', new FlagContext(userId: 'user-999')); // next rule or false

Anonymous users never match a whitelist

Requests with no userId in FlagContext skip the users rule entirely. The evaluation falls through to the next rule.

Percentage rollout

Assigns each user deterministically to a bucket via hash(flagName + userId) % 100. The same user always gets the same result for the same flag — no session state, no cookies, no database lookup per request.

php vortos vortos:flags:add-rule new-checkout \
  --type=percentage \
  --percentage=25
// Same user always gets the same result for the same flag
$flags->isEnabled('new-checkout', new FlagContext(userId: 'user-abc'));

The hash uses the flag name as a salt, so a user in bucket 30 for new-checkout may be in bucket 70 for pricing-test. Each flag has an independent distribution.

Percentage rules require a userId

Anonymous requests (no userId) always fall outside the bucket. Wire up a FlagContextResolver so authenticated users are identified.

Attribute rules

Target by any attribute in FlagContext::attributes. Attributes are arbitrary key/value pairs you provide in your FlagContextResolver.

OperatorBehaviour
equalsExact match
not_equalsDoes not match
inValue is in a comma-separated list
not_inValue is not in the list
containsValue contains the string as a substring
# Enterprise plan only
php vortos vortos:flags:add-rule feature \
  --type=attribute \
  --attribute=plan \
  --operator=equals \
  --value=enterprise

# Exclude free tier
php vortos vortos:flags:add-rule feature \
  --type=attribute \
  --attribute=plan \
  --operator=not_equals \
  --value=free

# EU region only
php vortos vortos:flags:add-rule feature \
  --type=attribute \
  --attribute=region \
  --operator=in \
  --value=eu-west,eu-central

# Email domain targeting (useful for beta programs)
php vortos vortos:flags:add-rule feature \
  --type=attribute \
  --attribute=email \
  --operator=contains \
  --value=@acme.com

Segments

A segment is a reusable set of targeting criteria. Instead of repeating the same user list or attribute rule across multiple flags, define it once and reference it by name.

# Create a segment for beta testers
php vortos vortos:flags:segment:create beta-users \
  --description="Beta program participants"

php vortos vortos:flags:segment:add-rule beta-users \
  --type=users \
  --users=user-1,user-2,user-3

# Reference the segment from a flag rule
php vortos vortos:flags:add-rule new-checkout \
  --type=segment \
  --segment=beta-users

When you add a user to the beta-users segment, every flag that references that segment immediately picks up the change. You do not need to update each flag individually.

Segments are also manageable through the admin UI under the Segments screen.

Deny rules

A deny rule explicitly blocks matched users from receiving the flag, regardless of any allow rules. Deny rules are evaluated before allow rules.

# Exclude a specific user from a percentage rollout
php vortos vortos:flags:add-rule new-checkout \
  --type=deny \
  --attribute=userId \
  --operator=equals \
  --value=problematic-user-id

Use deny rules to:

  • Exclude users who reported bugs from a rolling experiment
  • Block specific tenants from an early rollout
  • Override a segment match for one-off exceptions

Rule chain example

Combine rule types to build a staged rollout:

# Internal team always in
php vortos vortos:flags:add-rule new-checkout \
  --type=segment \
  --segment=internal-team

# Enterprise customers always in
php vortos vortos:flags:add-rule new-checkout \
  --type=attribute \
  --attribute=plan \
  --operator=equals \
  --value=enterprise

# 20% of everyone else
php vortos vortos:flags:add-rule new-checkout \
  --type=percentage \
  --percentage=20
Evaluation order:
  1. deny rules             → any deny match → false (stop)
  2. segment: internal-team → matched → true (stop)
  3. plan = enterprise      → matched → true (stop)
  4. percentage 20%         → hashed  → true/false (stop)
  5. fallback               → false

Checking flags in code

Inject FlagRegistry anywhere — controllers, services, query handlers:

use Vortos\FeatureFlags\FlagRegistry;
use Vortos\FeatureFlags\FlagContext;

final class CheckoutController
{
    public function __construct(private readonly FlagRegistry $flags) {}

    public function index(Request $request): Response
    {
        $context = new FlagContext(
            userId:     $this->currentUser->id(),
            attributes: ['plan' => $this->currentUser->plan],
        );

        if ($this->flags->isEnabled('new-checkout', $context)) {
            return $this->render('checkout/new.html.twig');
        }

        return $this->render('checkout/legacy.html.twig');
    }
}

When called without a context, the registered FlagContextResolver is used automatically:

$this->flags->isEnabled('new-checkout'); // uses FlagContextResolver

#[RequiresFlag] on controllers

Gate an entire route behind a flag. Returns 404 if the flag is off — no code in the action:

use Vortos\FeatureFlags\Attribute\RequiresFlag;

#[RequiresFlag('new-dashboard')]
final class NewDashboardController
{
    public function __invoke(): Response
    {
        // Only reachable when 'new-dashboard' is enabled for this request
        return $this->render('dashboard/new.html.twig');
    }
}

Compile-time resolution

#[RequiresFlag] is resolved at container compile time by FeatureFlagsCompilerPass. The compiler reads all annotated controllers and builds a static lookup map injected into FeatureFlagMiddleware. Zero reflection per request — just an array lookup.

On this page