From ed5826128e314b0bff17cac2ee7753ac1923c6e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?=
Date: Fri, 25 Sep 2026 16:15:02 +0200
Subject: [PATCH] feat: put the access codes behind a feature flag
---
.env | 11 ++
.env.dev | 3 +
.env.test | 4 +
composer.json | 1 +
composer.lock | 135 +++++++++++++++++-
config/bundles.php | 1 +
config/packages/flagception.yaml | 10 ++
config/reference.php | 59 ++++++++
src/Controller/AccessCode/IndexController.php | 2 +
.../Admin/AccessCode/CreateController.php | 2 +
.../Admin/AccessCode/DeleteController.php | 2 +
.../Admin/AccessCode/EditController.php | 2 +
.../Admin/AccessCode/IndexController.php | 2 +
symfony.lock | 12 ++
templates/dashboard/index.html.twig | 19 +--
15 files changed, 256 insertions(+), 9 deletions(-)
create mode 100644 config/packages/flagception.yaml
diff --git a/.env b/.env
index 51dea81..dc01332 100644
--- a/.env
+++ b/.env
@@ -77,3 +77,14 @@ EP_OAUTH2_URL_ACCESS_TOKEN=https://myep.ddev.site/token
EP_OAUTH2_URL_USERINFO=https://myep.ddev.site/api/userinfo
EP_OAUTH2_SCOPES=email,profile,roles
###< app/myep-oauth2 ###
+
+###> app/features ###
+# Feature flags, read by config/packages/flagception.yaml. Off here and switched on per
+# deployment in .env.local, so a feature reaches production when its environment says so rather
+# than when it is merged.
+#
+# Door, lift and barrier codes under /access-codes and their admin table. Off until the codes
+# have actually been entered: an empty list of codes is worse than no menu entry, because it
+# reads as "there are none" to somebody standing at the door.
+FEATURE_ACCESS_CODES=false
+###< app/features ###
diff --git a/.env.dev b/.env.dev
index a5084c0..0a05452 100644
--- a/.env.dev
+++ b/.env.dev
@@ -8,3 +8,6 @@ APP_SECRET=44660e8fcd421376ebe0f7a7d55e179c
# ddev serves the application under this name; anything else is refused.
SYMFONY_TRUSTED_HOSTS='^ephub\.ddev\.site$'
+
+# On for local development, so a fresh checkout sees the feature the branch is about.
+FEATURE_ACCESS_CODES=true
diff --git a/.env.test b/.env.test
index 3ae2840..17d38a6 100644
--- a/.env.test
+++ b/.env.test
@@ -3,3 +3,7 @@ KERNEL_CLASS='App\Kernel'
# Fixed and committed so the test suite is reproducible, and never served: .env.test is excluded
# from the deploy rsync and production takes APP_SECRET from its own uncommitted .env.local.
APP_SECRET='$ecretf0rt3st'
+
+# The access-code suite tests the feature, not the gate in front of it, so it runs with the flag
+# on.
+FEATURE_ACCESS_CODES=true
diff --git a/composer.json b/composer.json
index aa0c3ad..cd8268e 100644
--- a/composer.json
+++ b/composer.json
@@ -10,6 +10,7 @@
"doctrine/doctrine-bundle": "^3.3",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.7",
+ "flagception/flagception-bundle": "^6.0",
"knplabs/knp-paginator-bundle": "^6.10",
"league/oauth2-client": "^2.9",
"phpdocumentor/reflection-docblock": "^6.0",
diff --git a/composer.lock b/composer.lock
index 24eca64..bb99691 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "5651480dee3b63391faefc9646fe4a95",
+ "content-hash": "2aef6c416eb2231831a8f94f753f2aa3",
"packages": [
{
"name": "composer/semver",
@@ -1269,6 +1269,139 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
+ {
+ "name": "flagception/flagception",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/playox/flagception-sdk.git",
+ "reference": "6934e73ea1ab43611a43bf85922349a83561177b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/playox/flagception-sdk/zipball/6934e73ea1ab43611a43bf85922349a83561177b",
+ "reference": "6934e73ea1ab43611a43bf85922349a83561177b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2||^8.0",
+ "psr/cache": "^1.0||^2.0||^3.0",
+ "symfony/expression-language": ">=2.7"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.4.3",
+ "phpunit/phpunit": "*",
+ "squizlabs/php_codesniffer": "^3.5"
+ },
+ "suggest": {
+ "flagception/database-activator": "Fetch feature flags from database."
+ },
+ "type": "lib",
+ "autoload": {
+ "psr-4": {
+ "Flagception\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Michel Chowanski",
+ "email": "michel.chowanski@office-partner.de"
+ }
+ ],
+ "description": "Feature toggle on steroids.",
+ "keywords": [
+ "feature",
+ "feature-flags",
+ "feature-toggle",
+ "flagception",
+ "flags",
+ "rollout",
+ "testing",
+ "toggle"
+ ],
+ "support": {
+ "issues": "https://github.com/playox/flagception-sdk/issues",
+ "source": "https://github.com/playox/flagception-sdk/tree/2.0.0"
+ },
+ "time": "2025-02-26T16:11:43+00:00"
+ },
+ {
+ "name": "flagception/flagception-bundle",
+ "version": "6.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/playox/flagception-bundle.git",
+ "reference": "48f8fd0c8386aa3f4e66ba7882e24dc3ffcf8997"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/playox/flagception-bundle/zipball/48f8fd0c8386aa3f4e66ba7882e24dc3ffcf8997",
+ "reference": "48f8fd0c8386aa3f4e66ba7882e24dc3ffcf8997",
+ "shasum": ""
+ },
+ "require": {
+ "flagception/flagception": "^2.0",
+ "php": "^8.0",
+ "symfony/config": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/dependency-injection": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/http-kernel": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/yaml": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0",
+ "twig/twig": "^1.18|^2.0|^3.0"
+ },
+ "require-dev": {
+ "flagception/database-activator": "^2.0",
+ "php-coveralls/php-coveralls": "^2.0",
+ "squizlabs/php_codesniffer": "^3.3.1",
+ "symfony/phpunit-bridge": "^5.0 | ^6.0 | ^7.0 | ^8.0",
+ "symfony/twig-bridge": "^4.4 | ^5.0 | ^6.0 | ^7.0 | ^8.0"
+ },
+ "suggest": {
+ "flagception/database-activator": "Fetch feature flags from a database."
+ },
+ "type": "symfony-bundle",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/playox/flagception-sdk",
+ "name": "flagception/flagception"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Flagception\\Bundle\\FlagceptionBundle\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Michel Chowanski",
+ "email": "michel.chowanski@bestit-online.de"
+ }
+ ],
+ "description": "Feature toggle bundle on steroids.",
+ "keywords": [
+ "bundle",
+ "feature",
+ "feature-flags",
+ "feature-toggle",
+ "flagception",
+ "flags",
+ "rollout",
+ "symfony",
+ "toggle"
+ ],
+ "support": {
+ "issues": "https://github.com/playox/flagception-bundle/issues",
+ "source": "https://github.com/playox/flagception-bundle/tree/6.1.1"
+ },
+ "time": "2026-06-01T10:02:14+00:00"
+ },
{
"name": "guzzlehttp/guzzle",
"version": "7.15.5",
diff --git a/config/bundles.php b/config/bundles.php
index 8f204de..76477b7 100644
--- a/config/bundles.php
+++ b/config/bundles.php
@@ -14,4 +14,5 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
+ Flagception\Bundle\FlagceptionBundle\FlagceptionBundle::class => ['all' => true],
];
diff --git a/config/packages/flagception.yaml b/config/packages/flagception.yaml
new file mode 100644
index 0000000..2951d65
--- /dev/null
+++ b/config/packages/flagception.yaml
@@ -0,0 +1,10 @@
+# See full configuration at https://github.com/bestit/flagception-bundle
+#
+# Features are off unless an environment says otherwise: a deployment that knows nothing about a
+# flag gets the state the feature had before it existed. Switching one on is an .env change, not
+# a release.
+flagception:
+ features:
+ access_codes:
+ default: false
+ env: FEATURE_ACCESS_CODES
diff --git a/config/reference.php b/config/reference.php
index 17bad17..85fb803 100644
--- a/config/reference.php
+++ b/config/reference.php
@@ -1513,6 +1513,61 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* convert_exception?: bool|Param, // Default: false
* remove_first_page_param?: bool|Param, // Default: false
* }
+ * @psalm-type FlagceptionConfig = array{
+ * features?: array,
+ * routing_metadata?: array{
+ * enable?: bool|Param, // Default: true
+ * },
+ * activators?: array{
+ * array?: array{
+ * enable?: bool|Param, // Default: true
+ * priority?: int|Param, // Default: 255
+ * },
+ * constraint?: array{
+ * enable?: bool|Param, // Default: true
+ * priority?: int|Param, // Default: 210
+ * },
+ * environment?: array{
+ * enable?: bool|Param, // Default: true
+ * priority?: int|Param, // Default: 230
+ * },
+ * cookie?: array{
+ * enable?: bool|Param, // Default: false
+ * priority?: int|Param, // Default: 200
+ * name?: scalar|Param|null, // Default: "flagception"
+ * separator?: scalar|Param|null, // Default: ","
+ * mode?: "whitelist"|"blacklist"|Param, // Default: "whitelist"
+ * },
+ * database?: array{
+ * enable?: bool|Param, // Default: false
+ * priority?: int|Param, // Default: 220
+ * url?: scalar|Param|null, // Connection string for the database
+ * dbal?: scalar|Param|null, // Service with dbal instance
+ * credentials?: array{
+ * dbname?: scalar|Param|null,
+ * user?: scalar|Param|null,
+ * password?: scalar|Param|null,
+ * host?: scalar|Param|null,
+ * driver?: scalar|Param|null,
+ * },
+ * options?: array{
+ * db_table?: scalar|Param|null, // Default: "flagception_features"
+ * db_column_feature?: scalar|Param|null, // Default: "feature"
+ * db_column_state?: scalar|Param|null, // Default: "state"
+ * },
+ * cache?: array{
+ * enable?: bool|Param, // Default: false
+ * pool?: scalar|Param|null, // Default: "cache.app"
+ * lifetime?: int|Param, // Default: 3600
+ * },
+ * },
+ * },
+ * }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1527,6 +1582,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* monolog?: MonologConfig,
* ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
+ * flagception?: FlagceptionConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1544,6 +1600,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* maker?: MakerConfig,
* ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
+ * flagception?: FlagceptionConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1559,6 +1616,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* monolog?: MonologConfig,
* ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
+ * flagception?: FlagceptionConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1575,6 +1633,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* monolog?: MonologConfig,
* ux_icons?: UxIconsConfig,
* knp_paginator?: KnpPaginatorConfig,
+ * flagception?: FlagceptionConfig,
* },
* ...Alle Buchungen verwalten.
-
-
- {{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
- Zugangscodes
-
- Codes für Türen, Aufzug und Schranke.
-
+ {# The route 404s while the feature is off, so the card has to go with it. #}
+ {% if feature('access_codes') %}
+
+
+ {{ ux_icon('lucide:key-round', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
+ Zugangscodes
+
+ Codes für Türen, Aufzug und Schranke.
+
+ {% endif %}
{#
The header is deliberately not navigation, so the dashboard is the only place an