krak/symfony-rollout-rox
Symfony bundle for integrating Rox feature rollout management into PHP applications. Provides simple configuration and service wiring so you can control feature flags, target audiences, and gradual releases with minimal setup and no boilerplate. Ideal for teams adopting progressive delivery.
Install and wire it up. v0.1.0 is the first release of this bundle, and its headline feature is automatic and lazy loading of Rox containers — meaning you do not hand-instantiate the Rollout ROX client, and the SDK container is fetched lazily when first needed rather than at container build. That shapes everything below.
There is no Symfony Flex recipe, so registration is manual:
composer require krak/symfony-rollout-rox
// config/bundles.php (Symfony 4/5)
return [
// ...
Krak\SymfonyRolloutRox\RolloutRoxBundle::class => ['all' => true],
];
Then configure it. The exact keys live in the bundle's DependencyInjection/Configuration.php — read that file first, it is the de facto documentation for a package with no docs and no users. Expect something shaped like:
# config/packages/rollout_rox.yaml
rollout_rox:
app_key: '%env(ROX_APP_KEY)%'
environment: '%env(ROX_ENV)%' # e.g. Production / Staging
# possibly: enabled, register_twig, default_flags, lazy
Where to look first, in order:
vendor/krak/symfony-rollout-rox/src/DependencyInjection/Configuration.php — every config knob..../DependencyInjection/*Extension.php — the service IDs and aliases you can inject, and how the lazy Rox container is wired..../Resources/config/services.* — whether services are public/private and what interfaces they implement.rollout/rox-php-sdk (or whatever SDK it wraps) — the bundle is a thin shell over it; the flag semantics live there.First use case: gate one risky feature behind a flag and let it be flipped without a deploy.
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class CheckoutController extends AbstractController
{
public function checkout(RoxService $rox) // verify the actual service ID
{
if ($rox->isEnabled('new-checkout-flow', false)) {
return $this->render('checkout/v2.html.twig');
}
return $this->render('checkout/v1.html.twig');
}
}
Run bin/console debug:container rox --show-private and bin/console debug:config rollout_rox to confirm the real names before writing much code — with zero dependents you will not find a blog post to copy from. Note: because the container loads lazily, debug:container will confirm the service wiring, but not that the Rox container actually loaded — see the warm-up tip below.
Always pass an explicit default. The single most important habit with any flag SDK: every evaluation takes a fallback so a missing flag, an uninitialized SDK, or a network failure degrades to off rather than throwing.
$enabled = $rox->isEnabled('new-checkout-flow', false);
Wrap it in your own tiny service so call sites don't depend on the vendor type:
final class Features
{
public function __construct(private RoxService $rox) {}
public function newCheckout(): bool
{
return $this->rox->isEnabled('new-checkout-flow', false);
}
}
That indirection is what lets you swap the implementation later without touching controllers, and it gives you one place to add logging or caching.
Lean on the lazy loading, but warm it deliberately. v0.1.0's lazy container loading means the first flag evaluation in a given process is the one that pays the fetch cost — not container build. That's the right default, but it means your first request (or first queue job) of each PHP-FPM worker / Messenger worker is slower. Add a boot-time warm-up so the cost lands in a place you control:
// src/EventListener/RoxWarmupListener.php — or a console command run at deploy
class RoxWarmupListener
{
public function __construct(private RoxService $rox) {}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) return;
// Force the lazy container to load during the request, before any flag evaluation.
$this->rox->isEnabled('__warmup__', false);
}
}
Under Symfony CLI this is one-shot per command process; under long-running workers (Octane, FrankenPHP, Messenger --daemon) the container is loaded once and then hot, which is where lazy loading really shines.
Set user context early, in one listener. Flag targeting is useless without a stable identity and attributes. Do it once during kernel.request with a high priority so nothing evaluates flags before it:
class RoxContextListener
{
public function __construct(private RoxService $rox) {}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$user = $this->security->getUser();
$this->rox->setUser($user?->getId() ?? 'anonymous', [
'plan' => $user?->getPlan() ?? 'free',
'locale' => $event->getRequest()->getLocale(),
'betaTester' => (bool) $user?->isBetaTester(),
]);
}
}
Attributes must be stable strings/bools — targeting rules are matched on the values you send, so don't send raw entities or floats-with-varying-precision.
Twig gating. If the bundle registers a Twig extension, use it for template-level kill switches; otherwise add two lines of your own:
{% if rox_flag('new-checkout-flow', false) %}
{% include 'checkout/_v2.html.twig' %}
{% else %}
{% include 'checkout/_v1.html.twig' %}
{% endif %}
Be sparing: flag checks inside templates multiply fast and turn into unreadable nesting. Prefer computing booleans in the controller and passing them down. Remember that a Twig check is also an evaluation — it will trigger the lazy load if nothing earlier did.
One config block per environment, same flags everywhere. Point ROX_APP_KEY at different keys per env via .env.local / secrets, but keep flag names identical across dev → staging → prod. Divergent names is the classic way to ship a flag that "works in staging."
Testing. Never let tests hit the Rollout network. Disable the bundle in config/packages/test/rollout_rox.yaml (enabled: false or whatever the bundle's kill switch is), and in unit tests inject a hand-rolled fake implementing the same interface:
final class FakeFeatures implements Features
{
public array $flags = [];
public function newCheckout(): bool { return $this->flags['new-checkout-flow'] ?? false; }
}
Write at least one test per flag branch — the whole point of flags is that both paths are live, so both paths need coverage. With lazy loading, be aware that a test that doesn't disable the bundle may hit the network on first evaluation, not on container boot — easy to miss.
Degrade gracefully. Even though v0.1.0 is lazy, "automatic" loading means the first evaluation can still throw if the endpoint is unreachable. A decorator that catches SDK exceptions and returns defaults for every flag is cheap insurance:
final class SafeFeatures implements Features
{
public function __construct(private Features $inner, private LoggerInterface $log) {}
public function newCheckout(): bool
{
try { return $this->inner->newCheckout(); }
catch (\Throwable $e) { $this->log->error('rox failure', ['e' => $e]); return false; }
}
}
It is a v0.1.0 — early, not mature. The 2020-era "abandoned" smell is now replaced by "brand new": zero dependents, one release, and a headline feature (lazy container loading) that changes behavior in ways the docs don't explain. Before adopting: check its composer.json for PHP and Symfony constraints (^3.4|^4.0 is likely), confirm it installs against your PHP 8.x / Symfony 6/7 stack, and read the source in vendor/ rather than trusting the README. Expect breaking changes before 1.0 — pin the exact version. If it fights you for more than an hour, the honest move is to skip the bundle entirely and wire the underlying Rollout ROX PHP SDK into services.yaml yourself — that is roughly what the bundle does, and you'll own the code.
Lazy loading is not free. The bundle now defers the Rox container load until first evaluation — good — but "first evaluation" happens per process. With PHP-FPM that's the first request on each worker after restart; with Messenger it's the first job; with a short-lived CLI command it's every run. Expect a latency spike on the cold path and a hang if the endpoint is slow during a deploy-time console command. Warm it deliberately (see above) and disable the bundle in CLI contexts you don't need it in.
"Automatic" loading hides the trigger point. Because v0.1.0 loads the container for you, there is no explicit $client->initialize() call to grep for — if flag evaluation is slow or fails, you won't immediately see where. Log around the first evaluation per process (a simple static $warmed flag) so you can spot the cold path in your traces.
Laravel developers: this is Symfony-only. The bundle targets Symfony bundles/DI and won't drop into a Laravel app. If you're on Laravel, install the underlying ROX PHP SDK directly and bind it as a singleton in a service provider — the Features wrapper pattern below is the part worth reusing, not the bundle.
Ordering of user context. If a flag is evaluated in a kernel.request listener that runs before your context listener, it evaluates as "anonymous" and you get the wrong bucket — silently. Give the context listener a priority above anything that checks flags (e.g. priority: 100), and remember sub-requests: guard with isMainRequest().
"Why is the flag false?" debugging checklist:
debug:config rollout_rox — is the app key actually populated, or is it the literal string %env(ROX_APP_KEY)% because the env var is missing?debug:container — is the service you injected the real one, or did autowiring bind something unexpected?Missing defaults throw or flip. Some SDK versions require a default argument on isEnabled(), others silently return false. Never rely on the implicit behavior — pass the default, always.
Private services. Services registered by the bundle are probably private in modern Symfony, so $container->get(...) from a controller/test will fail. If you need container access for debugging, add a public alias in services.yaml:
services:
debug.rox:
alias: krak.rollout_rox.service_id
public: true
No typed contract. Because you're depending on a vendor class, any SDK upgrade is a potential breaking change across your codebase — and with a fresh v0.1.0, more are likely. The Features wrapper above is the real mitigation — it's the extension point that matters, not anything the bundle exposes.
Flag hygiene. With no dashboard-side enforcement from this package, discipline is on you: name flags with a date or ticket (checkout-v2-2024-q3), log every evaluation in dev, and delete the flag and the dead branch within a sprint or two of full rollout. Feature-flag debt is the most common way these integrations rot.
How can I help you explore Laravel packages today?