Only Doctrine and cache
Shizuku stores your feature flags in the database via Doctrine ORM. No external service, no YAML file.
In my job, every feature lived on its own long-lived branch. Merge day was a war — conflicts everywhere, deploys blocked, everyone waiting on everyone. The real problem wasn't the code, it was the coupling between shipping and toggling. I needed a way to merge early, deploy often, and decide when to switch a feature on without depending on an external dashboard, a third-party service, or a YAML file to redeploy.
So I built Shizuku. A flag in the database, a console command, and nothing else in the way.
feature(), cache TTL, caller-chain tracking.Dependencies
| Package | Version | Role |
|---|---|---|
| php | ≥ 8.2 | Required by Symfony 7.4+ |
| symfony/http-kernel | ^7.4 · ^8.0 | AbstractBundle, DataCollector |
| symfony/console | ^7.4 · ^8.0 | CLI commands |
| doctrine/orm | ^3.0 | Entity persistence |
| doctrine/doctrine-bundle | ^2.0 · ^3.0 | ServiceEntityRepository |
| twig/twig | ^3.0 | Function feature() |
| symfony/cache-contracts | ^3.0 | Cache interface (optional) |
symfony/cache for flag caching and
symfony/web-profiler-bundle for the Profiler panel —
wired up automatically if present.
Setup
01 — Require
02 — Register the bundle
// config/bundles.php
return [
Devexploris\ShizukuFeatureFlags\ShizukuFeatureFlagsBundle::class => ['all' => true],
];
03 — Create the table
The bundle ships no migrations. Generate one from your application:
Usage
PHP
Inject FeatureFlagService and call isEnabled(). An unknown flag always returns false.
use Devexploris\ShizukuFeatureFlags\Service\FeatureFlagService;
class MyService
{
public function __construct(private FeatureFlagService $flags) {}
public function doSomething(): void
{
if ($this->flags->isEnabled('my_feature')) {
// do something
}
}
}
Twig
The bundle registers the global function feature(). Unknown flags silently return false — no exception.
{% if feature('my_feature') %}
{# nouveau comportement #}
{% endif %}
{# inline — ex. bascule de classe CSS #}
isEnabled() and feature() return false.
The Symfony Profiler reports it in its panel to catch silent typos.
Console commands
Flag names must be snake_case: lowercase letters, digits and underscores, starting with a letter.
List
filter is optional. All, Enabled, Disabled, Locked. Without argument, an interactive prompt is shown.
Create
Flags are created disabled by default. Pass --enable to start them active.
Enable / Disable
Locked flags cannot be enabled or disabled.
Lock
Locking freezes the flag state permanently. It can no longer be enabled or disabled.
This is a one-way operation: there is no unlock command.
A locked flag signals that its feature has reached its final state and that the surrounding
isEnabled() calls are now dead weight to be removed from the codebase.
Delete
Only locked flags can be deleted by default. --force bypasses the restriction (with confirmation).
Deleting a flag does not remove its calls from your code.
Any remaining isEnabled('my_feature') or feature('my_feature') will silently return false
once the flag is gone, your feature will appear broken with no error, no exception, no log.
A locked+enabled flag is not meant to live in the codebase forever.
Remove every if (isEnabled(...)) section, then delete the flag.
The Profiler panel lists locked flags as a reminder until they are gone.
Flag lifecycle
| 01 | Flag created disabled by default (or active with --enable). |
| 02 | Can be freely toggled between enabled and disabled until locked. |
| 03 | Once locked, the state is frozen and cannot be changed. No unlock exists. The feature is considered shipped or abandoned — the flag is now technical debt. |
| 04 | Clean the code — remove every isEnabled() and feature() call. Keep the winning code path, delete the losing one. |
| 05 | Only then, delete the flag. A flag deleted without code cleanup will silently return false — the feature breaks with no warning. |
Caching
When cache.app is available in the container, the bundle wires it up automatically.
Flag state is cached for 300 seconds and immediately invalidated
on any mutating command (enable, disable, lock, delete, create).
Custom pool
# config/services.yaml
Devexploris\ShizukuFeatureFlags\Service\FeatureFlagService:
arguments:
$cache: '@cache.my_custom_pool'
Disable cache
# config/services.yaml
Devexploris\ShizukuFeatureFlags\Service\FeatureFlagService:
arguments:
$cache: ~
Symfony Profiler
When symfony/web-profiler-bundle is installed, a
Feature Flags panel is added to the toolbar automatically.
| Element | Description |
|---|---|
| Checked / enabled | Number of isEnabled() calls and how many returned true. |
| Locked flags | Frozen flags awaiting cleanup, displayed as "Must be cleaned before removal". |
| Unknown flags | Flags called in code but missing from the database. The toolbar highlights in orange. |
| Caller chain | Full Class::method chain that triggered each isEnabled(), vendor frames excluded. |
Entity Reference
| Property | Type | Description |
|---|---|---|
| name | string | Unique snake_case identifier, validated at creation. |
| description | string | Human-readable label. |
| isEnabled | bool | Whether the flag is currently active. |
| isLocked | bool | Whether the flag is frozen awaiting cleanup. |
| createdAt | DateTimeImmutable | Set automatically on persistence. |
| enabledAt | DateTimeImmutable|null | Set on first activation, reset to null on disable. |
| lockedAt | DateTimeImmutable|null | Set when locked. |