c975l / config-bundle
A Symfony bundle that stores application configuration as key-value pairs in the database, with an EasyAdmin management interface, Twig/PHP accessors, and production deployment tooling.
Package info
Type:symfony-bundle
pkg:composer/c975l/config-bundle
Requires
- php: >=8.0
- c975l/ui-bundle: *
- doctrine/orm: *
- easycorp/easyadmin-bundle: *
- sensiolabs/minify-bundle: *
- symfony/form: *
- symfony/serializer: *
- symfony/yaml: *
Requires (Dev)
This package is auto-updated.
Last update: 2026-07-12 10:06:24 UTC
README
A Symfony bundle that stores application configuration as key-value pairs in the database, with an EasyAdmin management interface, Twig/PHP accessors, and production deployment tooling.
Features
- Key-value config entries stored in the database (
site_configtable) - EasyAdmin CRUD interface to manage values
- Export button (SQL/CSV/JSON) for production deployment, reusable from any bundle's CRUD controller
- Twig and PHP service to read values anywhere
- 1-hour cache with automatic invalidation on change
- "What's new" dashboard section aggregating release notes declared by every c975L bundle
- Dashboard alerts (danger/warning/info) aggregating what needs attention, declared by every c975L bundle
Installation
composer require c975l/config-bundle
Run the database migration to create the site_config table:
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate
Defining config entries for your bundle
Create a config/configs.json file in your bundle. Each entry will be inserted into the database on first load (duplicates are skipped):
[
{
"label": "Site Name",
"slug": "site-name",
"sensitive": false,
"value": null,
"kind": "text",
"group": "general",
"description": "Name of the website"
},
{
"label": "Maintenance Mode",
"slug": "site-maintenance",
"sensitive": true,
"value": "false",
"kind": "bool",
"group": "system",
"description": "Set to true to enable maintenance mode"
},
{
"label": "Stripe Secret Key",
"slug": "stripe-secret-key",
"sensitive": true,
"restricted": true,
"value": null,
"kind": "text",
"group": "payment",
"description": "Stripe secret key (sk_live_...)"
}
]
Supported kind values: text, html, int, bool, date, json.
text is edited as a plain textarea (URLs, ids, emails...); html is for rare configs needing rich content and is edited with EasyAdmin's own rich text editor (same widget as UiBundle blocks).
For json, value is the raw JSON-encoded string (e.g. "[\"ROLE_ADMIN\",\"ROLE_EDITOR\"]"); ConfigService::get() returns it already decoded into a PHP array ([] if empty/invalid).
Set sensitive: true for any entry that holds secrets (API keys, passwords, etc.) — the value is encrypted at rest and masked in the admin list.
Set restricted: true on top of that for secrets shared across the whole install rather than per-site data — see Restricting configs to ROLE_SUPER_ADMIN.
group is optional and clusters entries in the EasyAdmin list (filter + default sort). It must be one of the fixed values in Config::GROUPS, each backed by a label.group_* translation key:
| Value | Meaning |
|---|---|
system |
Access control, maintenance mode |
general |
Site identity (name, logo, favicon, URL...) |
legal |
Terms of use, cookies, legal notice, DPO |
credits |
Hosted-by / made-by links and logos |
analytics |
Matomo and other tracking |
backup |
Database backup settings |
email |
Sender/recipient addresses |
form |
Contact form behavior (anti-spam delay, GDPR consent) |
security |
ReCaptcha and similar anti-abuse keys |
shop |
Currency, shipping, shop identity |
payment |
Payment provider keys (Stripe...) |
This list is closed on purpose so filtering stays useful; if none fits, leave group unset rather than inventing a new value (adding one requires extending Config::GROUPS and the matching translations in ConfigBundle itself).
severity is optional and flags an entry that needs an admin's attention as long as its value is empty — it never affects front-end rendering, ConfigService::get() still returns null/empty as before. It must be one of Config::SEVERITIES: danger, warning, info. Any entry with a severity and no value is listed on the /management dashboard as a colored alert with a direct link to fill it in; once a value is set, the alert disappears on its own (no flag to unset).
Loading config entries into the database
Auto-discovers every vendor/c975l/*/config/configs.json file and loads them in one shot:
php bin/console c975l:config:load-all
New entries (new slug) are inserted with their value from the JSON. For entries that already exist, only the metadata fixed by the bundle author — label, kind, group, severity, description, restricted — is re-synced from the JSON on every run; value and sensitive carry production state and are never touched, so editing a configs.json file (e.g. moving a config to a new group, fixing a typo in a label) and re-running load-all is enough to propagate the change, without risking an admin-set value.
Encrypting sensitive values
Sensitive config values can be encrypted at rest (AES-256-CBC) using a C975L_VAULT_KEY defined in .env.local. Generate a key:
php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
Add it to .env.local:
C975L_VAULT_KEY=<generated_key>
Then run the following command to encrypt any sensitive value still stored in plain text — it is idempotent and safe to run multiple times, skipping empty or already-encrypted values:
php bin/console c975l:config:encrypt-sensitive
EasyAdmin interface
The bundle registers a management dashboard at /management. Navigate to Config to view entries and edit their value — label, slug, kind, group, severity, and description are fixed by the bundle's configs.json and shown read-only; there is no manual creation or deletion, entries only come from configs.json.
Any entry with a severity and an empty value shows up as a colored alert (danger/warning/info) right on the /management home page, each linking directly to its edit form.
JS assets loaded on the dashboard
The /management dashboard loads a dedicated AssetMapper entry, @c975l/ui-bundle/admin.js (not your site's main app entry), so that satellite bundles needing Stimulus controllers in the back-office (e.g. c975l/ui-bundle's block editor) don't drag your site's front-end stylesheet into EasyAdmin. See the UiBundle README for how to define this entry.
Deploying to production — Export
On the config list page, click the Export dropdown and pick SQL, CSV, or JSON. The browser downloads a site_config_YYYYMMDD_HHMMSS.{sql,csv,json} file — nothing is written to disk or version control.
Import the SQL export on your production server:
mysql -u user -p dbname < site_config_20260626_120000.sql
Behavior per entry type (SQL export only):
is_sensitive |
SQL statement | Effect on production |
|---|---|---|
false |
INSERT … ON DUPLICATE KEY UPDATE |
Creates or updates label, value, kind, group, description, severity |
true |
INSERT IGNORE INTO |
Creates if missing; preserves existing production value |
| This means non-sensitive values (labels, descriptions, default content) are kept in sync, while live API keys and secrets already set on production are never overwritten. |
CSV and JSON exports are a straight dump of the table (no upsert logic) — useful for backups, audits, or feeding another tool.
Restricting configs to ROLE_SUPER_ADMIN
Some configs are secrets shared across the whole install rather than per-site application data —
a database backup user, a payment provider's live API key. Anyone with site-role-admin access
to the Config admin can normally see and edit every entry (encrypted sensitive values are masked
in the list but still revealed in clear on the detail/edit page). Flagging an entry
"restricted": true in its configs.json takes it a step further: that config disappears
entirely — from the index list, the detail page, the edit page, and every export (SQL/CSV/JSON) —
for anyone who isn't granted ROLE_SUPER_ADMIN, regardless of what site-role-admin is set to.
This is opt-in per entry (not per group), so a bundle only restricts the specific secrets that
need it, leaving the rest of its configs manageable by a regular site admin. ROLE_SUPER_ADMIN is
a plain Symfony role, not declared or granted by ConfigBundle itself — the consuming app (or a
bundle like c975l/site-bundle) decides who holds it.
Adding an Export button to another bundle's CRUD controller
c975L\ConfigBundle\Service\Export\TableExporter is generic: give it a table name and an array
of associative rows (e.g. from Connection::fetchAllAssociative()), it returns a ready-to-serve
Response encoded as SQL, CSV, or JSON (via Symfony's Serializer — CsvEncoder/JsonEncoder
plus a custom SqlEncoder). Wire it into your own AbstractCrudController the same way
ConfigCrudController does:
use c975L\ConfigBundle\Service\Export\ExportFormat; use c975L\ConfigBundle\Service\Export\TableExporter; use Doctrine\DBAL\Connection; use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\ActionGroup; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use Symfony\Component\HttpFoundation\Response; class MyEntityCrudController extends AbstractCrudController { public function __construct( private readonly Connection $connection, private readonly TableExporter $tableExporter, ) {} public function configureActions(Actions $actions): Actions { $exportGroup = ActionGroup::new('export', 'Export', 'fa fa-download') ->createAsGlobalActionGroup() ->addAction(Action::new('exportSql', 'SQL')->linkToCrudAction('exportSql')) ->addAction(Action::new('exportCsv', 'CSV')->linkToCrudAction('exportCsv')) ->addAction(Action::new('exportJson', 'JSON')->linkToCrudAction('exportJson')) ; return $actions->add(Crud::PAGE_INDEX, $exportGroup); } #[AdminRoute] public function exportSql(AdminContext $context): Response { // Set 'primary_key' to enable ON DUPLICATE KEY UPDATE; omit it for a plain INSERT-only dump return $this->tableExporter->export(ExportFormat::Sql, 'my_table', $this->fetchRows()); } #[AdminRoute] public function exportCsv(AdminContext $context): Response { return $this->tableExporter->export(ExportFormat::Csv, 'my_table', $this->fetchRows()); } #[AdminRoute] public function exportJson(AdminContext $context): Response { return $this->tableExporter->export(ExportFormat::Json, 'my_table', $this->fetchRows()); } private function fetchRows(): array { return $this->connection->fetchAllAssociative('SELECT * FROM `my_table`'); } }
export()'s 4th argument is an optional context array, forwarded to the encoder — only SqlEncoder
reads it:
| Key | Type | Effect |
|---|---|---|
primary_key |
string |
Unique column; adds ON DUPLICATE KEY UPDATE on every other column. Omit for a plain INSERT INTO per row. |
exclude_from_update |
string[] |
Columns never rewritten by the UPDATE clause (e.g. an immutable creation date). |
insert_ignore_when |
callable(array $row): bool |
When true for a row, emits INSERT IGNORE INTO instead of the upsert — see ConfigCrudController::exportSql() for the sensitive-value use case. |
Contributing menu items from other bundles
Satellite bundles add entries to the /management dashboard by implementing MenuProviderInterface — no manual service tagging needed, MenuProviderPass auto-detects any class implementing it.
namespace c975L\MyBundle\Management; use c975L\ConfigBundle\Management\MenuProviderInterface; use c975L\MyBundle\Controller\Management\MyCrudController; class MenuProvider implements MenuProviderInterface { public function getMenuSection(): array { return [ 'label' => 'label.my_section', 'translation_domain' => 'my_bundle', ]; } public function getMenus(): array { return [ 'my_entity' => [ 'controller' => MyCrudController::class, 'label' => 'label.my_entity', 'translation_domain' => 'my_bundle', 'icon' => 'fas fa-star', ], ]; } // Links to plain routes (not EasyAdmin CRUD controllers); return [] if none public function getLinks(): array { return []; } }
Make sure your bundle's services.yaml includes the Management/ folder in its src/ resource so the class is registered.
Section merging: if several bundles declare the same getMenuSection() (identical label + translation_domain), their menus are merged under a single section header instead of being duplicated.
Alphabetical ordering: within a section, menu items are always sorted alphabetically by their translated label.
Links section: getLinks() exposes links to plain routes (e.g. a public page), each entry shaped like:
public function getLinks(): array { return [ 'shop' => [ 'name' => 'shop_index', 'label' => 'label.shop', 'translation_domain' => 'shop', 'icon' => 'fas fa-shop', ], ]; }
Links from every bundle are merged into a single "Links" section (opened in a new tab), sorted alphabetically.
Contributing linkable routes for SiteBundle menus
SiteBundle lets site admins add navbar/footer menu items that link to an existing database Page, or to a route contributed by another bundle (e.g. ContactFormBundle's /contact). This interface lives here (not in SiteBundle) precisely so that bundles which don't depend on SiteBundle (ContactFormBundle, ShopBundle, BookBundle...) can still expose a route, by implementing LinkableRouteProviderInterface — no manual service tagging needed, LinkableRouteProviderPass auto-detects any class implementing it, same mechanism as MenuProviderInterface above.
namespace c975L\MyBundle\Management; use c975L\ConfigBundle\Management\LinkableRouteProviderInterface; class LinkableRouteProvider implements LinkableRouteProviderInterface { // Route name => ['label' => translation key, 'translation_domain' => domain]; return [] if none public function getLinkableRoutes(): array { return [ 'my_bundle_display' => [ 'label' => 'label.my_page', 'translation_domain' => 'my_bundle', ], ]; } }
Make sure your bundle's services.yaml includes the Management/ folder in its src/ resource so the class is registered.
Routes are checked live: if the contributing bundle is later removed (or its provider stops returning that route), any menu item pointing to it simply disappears from the rendered menu instead of producing a broken link.
Contributing "What's new" entries from other bundles
The /management dashboard shows the 5 latest release notes merged from every c975L bundle, with a link to the full list at /management/whatsnew.
This is a marketing-style feed for non-developer back-office users, not a developer changelog (see ChangeLog.md for that) — there's no version or bundle field, and entries should read as user-facing benefits, not technical changes.
Declare your bundle's entries in a config/whatsnew.json file:
[
{
"date": "2026-07-04",
"description": [
{
"en": "Added a new XYZ block",
"fr": "Ajout d'un nouveau bloc XYZ",
"es": "Añadido un nuevo bloque XYZ"
}
]
}
]
Expose them via a WhatsNewProvider implementing WhatsNewProviderInterface — no manual service tagging needed, WhatsNewProviderPass auto-detects any class implementing it (same pattern as MenuProviderInterface):
namespace c975L\MyBundle\Management; use c975L\ConfigBundle\Management\WhatsNewJsonReader; use c975L\ConfigBundle\Management\WhatsNewProviderInterface; class WhatsNewProvider implements WhatsNewProviderInterface { public function getEntries(): array { return WhatsNewJsonReader::read(\dirname(__DIR__, 2) . '/config/whatsnew.json'); } }
Make sure your bundle's services.yaml includes the Management/ folder in its src/ resource so the class is registered.
UiBundle exception: UiBundle cannot depend on c975l/config-bundle (the dependency already runs the other way, ConfigBundle → UiBundle), so it doesn't implement WhatsNewProviderInterface. It contributes entries through its own WhatsNewRegistry (same pattern as ScriptAdminRegistry) — see the UiBundle README for how to register entries there; WhatsNewBuilder merges them in automatically alongside every other bundle's entries.
Contributing dashboard alerts from other bundles
The /management dashboard, and each CRUD's own index page, can show a severity-grouped alert list (danger/warning/info) pointing at whatever needs attention — e.g. configs missing a value.
Satellite bundles contribute alerts by implementing AlertProviderInterface — no manual service tagging needed, AlertProviderPass auto-detects any class implementing it (same pattern as MenuProviderInterface):
namespace c975L\MyBundle\Management; use c975L\ConfigBundle\Entity\Config; use c975L\ConfigBundle\Management\AlertProviderInterface; class MyAlertProvider implements AlertProviderInterface { public function getAlerts(): array { return [ [ 'label' => 'My entity label', 'description' => 'Why it needs attention', 'severity' => Config::SEVERITY_WARNING, 'url' => '/management/my-entity/edit/1', ], ]; } }
Make sure your bundle's services.yaml includes the Management/ folder in its src/ resource so the class is registered.
Dashboard aggregation: AlertBuilder::getAlerts() merges every provider's alerts and groups them by severity for the main /management dashboard.
Own CRUD index: a controller that only wants its own provider's alerts (not every bundle's) calls AlertBuilder::groupBySeverity() directly on that provider's flat list — see ConfigCrudController for an example.
Rendering: both cases are rendered with the shared templates/management/_alerts.html.twig partial, which expects a severity-grouped alerts array and a translated title.
Contributing dashboard shortcuts from other bundles
The /management dashboard shows a row of quick-action buttons (e.g. clearing a cache, toggling maintenance mode) contributed by any bundle.
Satellite bundles contribute shortcuts by implementing ShortcutProviderInterface — no manual service tagging needed, ShortcutProviderPass auto-detects any class implementing it (same pattern as MenuProviderInterface):
namespace c975L\MyBundle\Management; use c975L\ConfigBundle\Management\ShortcutProviderInterface; use c975L\MyBundle\Controller\Management\MyShortcutController; use Symfony\Contracts\Translation\TranslatorInterface; class MyShortcutProvider implements ShortcutProviderInterface { public function __construct( private readonly TranslatorInterface $translator, ) { } public function getShortcuts(): array { return [ [ 'label' => $this->translator->trans('label.toggle_maintenance', [], 'my_bundle'), 'icon' => 'fas fa-wrench', 'route' => MyShortcutController::TOGGLE_MAINTENANCE_ROUTE, 'active' => $this->isMaintenanceOn(), ], ]; } }
Make sure your bundle's services.yaml includes the Management/ folder in its src/ resource so the class is registered.
Unlike menus/links, shortcuts trigger an action, not just navigation. route must accept a POST request and validate its own CSRF token (csrf_token(route) is the token id used by the shared template) — see ConfigShortcutController::clearCache() for a one-shot reference implementation that clears the config cache.
active: styles the button (btn-danger when true, btn-outline-secondary otherwise) to reflect an on/off state. See MaintenanceShortcutController::toggle() for a toggle reference implementation flipping the site-maintenance config used by MaintenanceListener, with ConfigShortcutProvider::getShortcuts() reading that same config to decide active and pick the right label ("Enable"/"Disable"). One-shot actions with no on/off state can always return false.
Rendering: shortcuts are merged across every provider by ShortcutBuilder::getShortcuts() and rendered with the shared templates/management/_shortcuts.html.twig partial, each one as its own small <form method="post">.
Reading config values
In PHP
use c975L\ConfigBundle\Service\ConfigServiceInterface; class MyService { public function __construct( private readonly ConfigServiceInterface $configService, ) {} public function doSomething(): void { $siteName = $this->configService->get('site-name'); // string $maxItems = $this->configService->get('max-items'); // int (auto-cast) $isEnabled = $this->configService->get('feature-enabled'); // bool (auto-cast) $env = $this->configService->getContainerParameter('kernel.environment'); } }
In Twig
{# Read from database #} {{ config('site-name') }} {# Read from Symfony container parameters #} {{ configParam('kernel.environment') }}
License
MIT — see LICENSE.