Verified against Semitexa Ultimate 2026.10.20.0340
Feature Flags
Declare
A flag belongs to the module that uses it. Declare it on any class of that module — by convention a FeatureFlags class:
use Semitexa\Core\Attribute\AsFeatureFlag;
#[AsFeatureFlag('checkout.new-flow', description: 'The one-page checkout')]
#[AsFeatureFlag('search.semantic', default: true)]
#[AsFeatureFlag('editor.ai-assist', percent: 10)]
final class FeatureFlags {}
The declaration is the default and the documentation: default (off unless said), a description that feature:list shows, and an optional percent — on for that share of users. A name is lower-case letters, digits, ., _ and -. A flag nobody declared is always off.
Read
use Semitexa\Core\Attribute\InjectAsMutable;
use Semitexa\Core\Feature\FeatureFlagsInterface;
#[InjectAsMutable]
protected FeatureFlagsInterface $flags;
if ($this->flags->isEnabled('checkout.new-flow')) { ... }
{% if feature('checkout.new-flow') %} … {% endif %}
The answer is for the current request's site and signed-in user. A job or a command asks for someone explicitly: $flags->forSubject($tenantId, $userId)->isEnabled(...). Outside a request, feature() in a template answers as the injected service does there: for the site that work runs under, and without a user. A queue worker has no site, so that is the "every site" setting. A mail is the exception: feature() in its templates answers for the mail's tenantId (every site when it names none), and for no user, also when the mail is sent during a request. To render something else for one site, wrap the render in CurrentFeatureFlags::forSubjectDuring($tenantId, $userId, fn () => ...). A check never throws: when the store of overrides cannot be read, the declared default answers. Each worker logs that at most once a minute (channel feature), with the number of checks that fell back since its last warning.
Switch
bin/semitexa feature:set checkout.new-flow on # every site
bin/semitexa feature:set checkout.new-flow off --tenant=acme # one site
bin/semitexa feature:set checkout.new-flow on --tenant=acme --user=42 # one user of that site
bin/semitexa feature:set editor.ai-assist 25% # a rollout
bin/semitexa feature:set checkout.new-flow clear --tenant=acme # back to the next scope out
bin/semitexa feature:list --tenant=acme --user=42 # value, and where it comes from
The most specific setting wins: user, then site, then every site, then the declaration. feature:set refuses a flag no module declares (it would read as off anyway), and a --tenant that is neither default (the every-site row) nor one of the sites the tenant fanout knows (with semitexa/tenancy, the active tenants of its registry: TENANTS and TENANT_<ID>_*); --force stores it regardless. A --user is not checked: the user providers do not know which site a user belongs to. In code, FeatureFlagWriter checks the flag only. A user's own overrides, and every other setting of theirs, go when semitexa/platform-user deletes the account: it dispatches Semitexa\Core\Auth\Event\UserDeleted inside the deleting transaction, and the settings store removes that user's rows on every site. A project with its own settings store, or its own accounts, listens to or dispatches that event itself.
How it behaves
- Where it is kept:
semitexa/platform-settings, modulefeature_flags, one row per flag and scope. "Every site" is thedefaulttenant's row; on a single-site install that is also the site row. - How fast: a change reaches every worker within 2 seconds (the settings snapshot's lifetime); a user's flags are read once per request (a
feature()outside the route pipeline, such as on an error page, reads them on each call). Not a kill switch for something that must stop within the second. - Rollouts: a user is in or out by a hash of the flag and their id, so they get the same answer on every request and every worker; raising the share only adds users. A guest is decided by site. Changing the share moves users at the margin only.
- Without platform-settings: core answers the declarations alone.
- In tests:
new InMemoryFeatureFlags(['checkout.new-flow' => true])whereverFeatureFlagsInterfaceis expected.