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 → falseRule 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 falseAnonymous 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.
| Operator | Behaviour |
|---|---|
equals | Exact match |
not_equals | Does not match |
in | Value is in a comma-separated list |
not_in | Value is not in the list |
contains | Value 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.comSegments
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-usersWhen 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-idUse 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=20Evaluation 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 → falseChecking 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.