em411/sylius-feature-flag-bundle-plugin
Manage em411/feature-flag-bundle flags from the Sylius admin panel. Flags become a Sylius resource with a grid to create, edit and delete them, each with a code, enabled toggle, optional value and description. A Doctrine-backed provider overrides code-defined @AsFeature flags of the same code.
Install the package and its base bundle. The plugin is a Sylius-facing layer on top of em411/feature-flag-bundle (an ajgarlag/feature-flag-bundle fork), so both must be present:
composer require em411/sylius-feature-flag-bundle-plugin
Register both bundles in config/bundles.php, base bundle first:
return [
// ...
Ajgarlag\FeatureFlagBundle\FeatureFlagBundle::class => ['all' => true],
Em411\SyliusFeatureFlagPlugin\Em411SyliusFeatureFlagPlugin::class => ['all' => true],
];
Import the admin routing into config/routes.yaml (or a dedicated file in config/routes/), pointing at the plugin’s routing file as the README shows.
Create the table. Run bin/console doctrine:schema:update --force in dev (or generate/execute a migration). Then bin/console cache:clear.
First use case — a kill switch. From the Sylius admin, open the new Feature Flags grid, create a flag with a code you already reference in code (e.g. checkout.new_payment_step), leave it enabled with an empty value, and wrap the new behaviour in a check against that code. Disabling it from the admin immediately restores the old path — no deploy required.
If your Sylius version complains about a missing resource, also map the entity under sylius.resources in config/packages/_sylius.yaml and re-run the schema step.
Two-layer flags: code default + DB override. Declare a safe default in code with the base bundle’s attribute, then let admins override it from the grid:
#[AsFeature('checkout.new_payment_step')]
final class NewPaymentStep
{
// ...
}
Because the Doctrine-backed provider is registered at a high priority, a DB row with the same code wins over the @AsFeature attribute. This is the pattern to prefer: ship every flag with a code-defined default so production never depends on a row existing, then use the admin only for operational overrides.
Inject the flag manager where you need decisions. The base bundle exposes a manager service; inject it (constructor or action argument) rather than reading the DB directly. That keeps your code agnostic to whether a flag came from attributes, the database, or a test provider:
public function __construct(private FeatureFlagManagerInterface $flags) {}
public function __invoke(): Response
{
if ($this->flags->isEnabled('checkout.new_payment_step')) {
// new path
}
// old path
}
(Confirm the exact method name/Twig function against the base bundle’s API — this plugin only adds the admin resource and Doctrine provider.)
Use value for configuration, not just booleans. The value column is the ideal place for percentages, variant names, or JSON blobs:
$value = $this->flags->getValue('pricing.show_b2b_prices');
$config = json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR);
Wrap the decode and fall back to a hardcoded default — flags are edited by humans and a malformed JSON string should never break a page.
Seed flags in fixtures/migrations. For flags that must exist on fresh installs (e.g. a flag your templates read without a fallback), create the row in a Doctrine fixture or a migration rather than relying on manual admin entry.
Keep flag codes in a constants class. One PHP file listing every code (Flags::CHECKOUT_NEW_PAYMENT_STEP) prevents typos across @AsFeature, templates, and admin rows, and gives you a grep target when cleaning up dead flags.
Templating. For Twig-heavy storefront customisations, use the base bundle’s Twig helper (feature_flag(...) style) or pass a boolean from the controller, so the UI stays testable without hitting Doctrine in every template render.
@AsFeature declaring enabled: true in code is overridden by a disabled DB row. If a feature is unexpectedly off, check the admin grid before you debug the code. Conversely, deleting a row silently reverts to the code default — that is the intended emergency-reset behaviour, not a bug.sylius_admin firewall applies. Importing it at the root will leave the grid unauthenticated.value is a string column. Booleans, numbers, and JSON all round-trip as strings. Type-juggle explicitly at the boundary and never rely on === true.composer.json and keep the upgrade path in mind: the fallback to @AsFeature defaults means you can remove the package later without losing every flag, provided you have not built code that assumes DB rows exist.How can I help you explore Laravel packages today?