PHPackages                             c975l/config-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. c975l/config-bundle

ActiveSymfony-bundle[Utility &amp; Helpers](/categories/utility)

c975l/config-bundle
===================

Symfony bundle providing the EasyAdmin dashboard and database-backed configuration at the root of the c975L ecosystem — the shared hub every satellite bundle plugs into for menus, exports/imports, alerts, and other cross-bundle dashboard contributions.

v5.11.3(2w ago)21.1k20MITPHPPHP &gt;=8.0CI failing

Since Aug 26Pushed 1mo ago2 watchersCompare

[ Source](https://github.com/975L/ConfigBundle)[ Packagist](https://packagist.org/packages/c975l/config-bundle)[ Docs](https://github.com/975L/ConfigBundle)[ Fund](https://buymeacoff.ee/laurentmarquet)[ Fund](https://opencollective.com/laurent-marquet)[ RSS](/packages/c975l-config-bundle/feed)WikiDiscussions main Synced 2w ago

READMEChangelogDependencies (30)Versions (123)Used By (20)

c975L ConfigBundle
==================

[](#c975l-configbundle)

Symfony bundle providing the EasyAdmin dashboard and database-backed configuration at the root of the c975L ecosystem — the shared hub every satellite bundle plugs into for menus, exports/imports, alerts, and other cross-bundle dashboard contributions.

[![GitHub](https://camo.githubusercontent.com/d3670c02311c2c148f9a939b9256ec4786e7cb7064ba6004ea679e658d03e0c7/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f3937354c2f436f6e66696742756e646c65)](https://github.com/975L/ConfigBundle/blob/master/LICENSE)[![Packagist Version](https://camo.githubusercontent.com/e51ca217d8b0e4b6838654af8210dc84bd61aac60ed0b60b61533539c02b4544/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f633937356c2f636f6e6669672d62756e646c65)](https://packagist.org/packages/c975l/config-bundle)[![PHP Version](https://camo.githubusercontent.com/245aea4b99123b7db0a5ef5148b2096b2522241f0b097baa3aed1d1ff4df653c/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f633937356c2f636f6e6669672d62756e646c65)](https://packagist.org/packages/c975l/config-bundle)

Why ConfigBundle
----------------

[](#why-configbundle)

[![ConfigBundle](./.github/images/ConfigBundle.svg)](./.github/images/ConfigBundle.svg)

The root of the c975L ecosystem — every other bundle ([UiBundle](https://github.com/975L/UiBundle), [SiteBundle](https://github.com/975L/SiteBundle), [ShopBundle](https://github.com/975L/ShopBundle), [BookBundle](https://github.com/975L/BookBundle), [GalleryBundle](https://github.com/975L/GalleryBundle), [SocialBundle](https://github.com/975L/SocialBundle)...) depends on it, directly or through UiBundle. It's the single place for application configuration: no per-app `.env` for business config, no duplicated dashboard entry mechanism — a satellite bundle just implements `MenuProviderInterface` (or one of its siblings) and gets an EasyAdmin entry for free.

See it in action at [975l.com/pages/config-bundle](https://975l.com/pages/config-bundle).

---

Features
--------

[](#features)

- Key-value config entries stored in the database (`site_config` table)
- EasyAdmin CRUD interface to manage values
- `c975l:config:set` to fill values from the command line or a JSON file, for provisioning, deployment and tests
- "Obsolete configs" dashboard page and `c975l:config:prune` to delete entries no `configs*.json` declares anymore
- Export button (SQL/CSV/JSON/Sync-zip) for production deployment, reusable from any bundle's CRUD controller
- Zip-based content import/export for syncing nested bundle content across environments, extensible via `ImportProviderInterface`/`ExportProviderInterface`
- 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
- Dashboard "Essential actions" checklist, a permanent quick-access entry point to the handful of settings every site needs
- Dashboard widgets contributed by other bundles (e.g. UiBundle's Donovan card)
- Dashboard "Guided tour" walking through every sidebar item that declares a `description`
- "Health check" dashboard page (Lighthouse scores, security headers, W3C/accessibility checks...) with history, a trend chart, and CSV export, extensible via `HealthCheckProviderInterface`/`HealthCheckAdviceProviderInterface`
- Sitemap generation (one sub-sitemap per bundle plus the sitemap index), extensible via `SitemapProviderInterface`
- `c975l:dev-profile:run`, a dev-only command listing what the Symfony dev toolbar would flag on every page (n+1 queries, deprecations, missing translations...), extensible via `DevProfilePathProviderInterface`

Installation
------------

[](#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
---------------------------------------

[](#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`, `font`. `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). `font` renders a `` (UiBundle's `FontChoiceType`/`FontRegistry`) combining `Config::GENERIC_FONT_FAMILIES` (`serif`, `sans-serif`, `monospace`, always offered) with whatever custom font-family names a registered `FontProviderInterface` knows about (e.g. SiteBundle's `FontService`, parsed from a CSS file's `@font-face` declarations) — falls back to only the 3 generics when no provider is registered. A value no longer offered by either source (e.g. removed from `@font-face`) is kept selectable instead of being silently dropped on the next save. 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](#restricting-configs-to-role_super_admin).

`group` is optional and clusters entries on the "pick a group" screen (see below). It must be one of the fixed values in `Config::GROUPS`, each backed by a `label.group_*` translation key:

ValueMeaning`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...)`theme`Theme CSS variables (colors, fonts, light/dark mode)`ai`AI-related settings (LLM providers, prompts...)`messenger`Symfony Messenger cleanup settingsThis 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
----------------------------------------

[](#loading-config-entries-into-the-database)

Auto-discovers every `vendor/c975l/*/config/configs*.json` file **plus the application's own `config/configs*.json`**, and loads them in one shot — a bundle can ship several files (e.g. `configs.json` plus `configs-css.json` for theme variables), each loaded independently:

```
php bin/console c975l:config:load-all
```

The application file is loaded exactly like a bundle's one, so an app needing a setting no bundle declares (its own API keys, feature flags...) just drops a `config/configs.json` at its root and gets it in the dashboard, with no command of its own to write.

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`, `sensitive` — is re-synced from the JSON on every run; the `value` carries production state and is never overwritten, 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.

`sensitive` is the one flag whose change also touches the value, because the two can't be separated: an entry that becomes sensitive gets its value encrypted, one that stops being sensitive gets it decrypted. Without that, dropping `"sensitive": true` from a declaration would leave a `C975L:…` string sitting in what is now a plain-text setting. When the conversion can't be done — no `C975L_VAULT_KEY`, or a value encrypted with a different one — the flag is left as it was rather than storing something unusable, and the next run picks it up once the key is in place.

Pruning entries no longer declared
----------------------------------

[](#pruning-entries-no-longer-declared)

An entry dropped from a `configs*.json` (a setting replaced by a proper entity, a bundle uninstalled) stays in database forever: `load-all` only ever inserts and syncs metadata, it never deletes — and it says nothing about those leftovers either, being a deployment step whose output nobody reads. Removing them is an explicit step of its own. From the dashboard, the **Obsolete configs** shortcut (`ROLE_SUPER_ADMIN`) lists them with the value each deletion would take with it, and deletes the ones ticked. Or, without a browser:

```
php bin/console c975l:config:prune            # lists them, deletes nothing
php bin/console c975l:config:prune --force    # deletes them, after confirmation
```

Both share the same safeguard, because "undeclared" is only meaningful when the declarations are all there: neither reports a single orphan when no `configs*.json` is found at all, an unfinished `composer install` otherwise making every entry look orphaned, nor when one exists but can't be parsed, a single misplaced comma otherwise turning everything that file declares into an orphan. The command adds a confirmation prompt in interactive mode, the page its list of what is about to go. Deletion takes the stored value with it — export your configs first if a bundle is only temporarily uninstalled.

Setting values from the command line
------------------------------------

[](#setting-values-from-the-command-line)

`load-all` declares the entries, the EasyAdmin interface fills them in. To fill them in without a browser — provisioning a fresh environment, a deployment pipeline, a test fixture, restoring a site — use:

```
php bin/console c975l:config:set site-name "My Site"
```

Several entries at once, from a JSON file holding a `{"slug": "value"}` object:

```
php bin/console c975l:config:set --file=values.json
```

```
{
    "site-name": "My Site",
    "site-form-delay": 3,
    "user-roles-available": ["ROLE_ADMIN", "ROLE_EDITOR"],
    "stripe-secret": "sk_live_..."
}
```

Booleans, numbers and arrays are converted to the string stored in database, and each value is checked against its entry `kind` (`bool` only accepts `true`/`false`, `int` an integer, `json` valid JSON, `date` a parsable date).

OptionEffect`--if-empty`Only fills entries whose value is still empty, never overwrites one already set`--dry-run`Lists what would change without writing anythingThe command is meant to be re-run: an empty value is always skipped (an incomplete file never blanks out a live setting), an unchanged value is skipped too (no pointless `modification` date), and `--if-empty` makes a whole file idempotent — which is what a deployment pipeline wants, filling in whatever new entry the last `composer update` brought in while leaving production values alone.

Entries are never created here: an unknown slug is reported and the command exits non-zero, so a typo doesn't pass silently. Sensitive entries are encrypted with `C975L_VAULT_KEY` exactly as the back-office does, are masked in the output so no secret lands in a CI log, and are refused rather than stored in plain text when no key is defined.

Encrypting sensitive values
---------------------------

[](#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=
```

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
-------------------

[](#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`.

**Config** opens on a "pick a group" screen (one row per distinct `group`, with its entry count) rather than one flat table of every entry — picking a group filters the familiar EasyAdmin grid down to just that group's entries, with a "← Config" action to go back. This keeps the list readable as more bundles/groups accumulate; the entry count shown per group respects both the current sensitive/non-sensitive view and, below `ROLE_SUPER_ADMIN`, excludes restricted entries the viewer wouldn't see anyway.

Theme CSS variables (colors, fonts, light/dark mode, fixed by a bundle's `configs-css.json`) are entries like any other, under the `theme` group — reachable the same way, via **Config**'s "pick a group" screen, at the same `site-role-admin` permission as every other group (no dedicated page, no separate permission tier).

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

[](#js-assets-loaded-on-the-dashboard)

The `/management` dashboard loads dedicated AssetMapper entries (not your site's main `app` entry), so that satellite bundles needing Stimulus controllers in the back-office don't drag your site's front-end stylesheet into EasyAdmin. `c975l/ui-bundle` contributes one for its block editor — see the [UiBundle README](https://github.com/975L/UiBundle#installation) for how to define that entry.

ConfigBundle contributes its own, `@c975l/config-bundle/controllers-admin.js`, for the dashboard's guided tour (see [Contributing menu items from other bundles](#contributing-menu-items-from-other-bundles) below for how a bundle's own menu entries feed into it) and its Health check trend chart (see below). This entry (and any other c975L bundle's own admin JS) is added to your `importmap.php` automatically — see [Contributing importmap entries from other bundles](#contributing-importmap-entries-from-other-bundles) below, nothing to add by hand.

**`symfony/ux-chartjs`** is a regular Composer dependency (not something to add manually) - Symfony Flex registers `ChartjsBundle` and its own `importmap.php`/`chart.js` entries automatically the first time you `composer update` after installing/upgrading ConfigBundle.

That same Flex recipe also writes an **eager** entry into your app's `assets/controllers.json`, which you should turn off:

```
{
    "controllers": {
        "@symfony/ux-chartjs": {
            "chart": {
                "enabled": false,
                "fetch": "eager"
            }
        }
    },
    "entrypoints": []
}
```

`startStimulusApp()` statically imports every `enabled`+`eager` controller listed there, so leaving it on has two costs. On the **front-end**, your `app.js` pulls `chart.js` (~66 KiB transferred) onto every public page, where no chart is ever rendered. On the **`/management` dashboard**, each admin entry starts its own independent Stimulus app (see `DashboardController::configureAssets()`) and each one registers the chart controller again — with four c975L bundles installed, four applications call `new Chart()` on the same ``, which Chart.js rejects with *"Canvas is already in use"*.

On the dashboard, disabling it costs nothing: `controllers-admin.js` registers the chart controller explicitly, once. Use `"enabled": false` rather than `"fetch": "lazy"` — lazy fixes the front-end bytes but still lets every admin Stimulus app register the controller on its own. `c975l:config:check-importmap` warns when it finds the entry still enabled — the warning is about the dashboard, so ignore it if your app calls `render_chart()` on a public page too (that page does need the front-end controller, and `"fetch": "lazy"` is then the right trade-off).

### Deploying to production — Export

[](#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  int, 'updated' => int]
    public function import(array $items, ?string $filesDir = null): array
    {
        // ...
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

Uploaded zips are accepted at the **Import content** dashboard link (`ROLE_SUPER_ADMIN` — it writes arbitrary content straight into the database, unlike the export side which stays at `site-role-admin`), which extracts the zip, reads `manifest.json`'s `kind`, and dispatches to whichever registered provider's `supportsImport()` matches it.

Contributing export providers from other bundles
------------------------------------------------

[](#contributing-export-providers-from-other-bundles)

`ExportProviderInterface` is the export-side mirror of `ImportProviderInterface` above — same "kind" values, same natural-key philosophy. Implementing it makes your bundle's content part of the **Export sync (everything)** dashboard shortcut, without touching that shortcut's own code:

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Management\ExportProviderInterface;

class MyExportProvider implements ExportProviderInterface
{
    // The string embedded in the export payload for this provider's items (see ContentExporter), stable across dev/prod (e.g. "my_entity")
    public function getKind(): string
    {
        return 'my_entity';
    }

    // Same shapes ContentExporter::export() expects: 'items' (JSON-able array, one entry per exported entity) and 'files' (archive-relative path => disk path, empty for a kind that never carries files)
    public function exportAll(): array
    {
        return ['items' => $this->fetchItems(), 'files' => []];
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered — no manual service tagging needed, `TaggedInterfacePass` auto-detects any class implementing it, same mechanism as `MenuProviderInterface` below.

`SyncAllExporter` collects every registered `ExportProviderInterface` into a single zip (same `manifest.json`-plus-files shape as a single-kind **Sync** export, just with several `{kind, items}` blocks under `exports`) — a bundle that isn't installed simply doesn't contribute a section, no configuration needed on either side. On import, `ContentImportController` detects that multi-section shape automatically and dispatches each section to its own `ImportProviderInterface`, same as a single-kind zip.

Contributing menu items from other bundles
------------------------------------------

[](#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.

**Advanced tier:** both `getMenuSection()` and each entry in `getMenus()` accept an optional `'tier' => 'advanced'` key (default `'essential'`). Items opting into it are pulled out of their section and collected into one collapsed "Advanced" submenu at the bottom of the sidebar, instead of staying under their own section header — set it on `getMenuSection()` to move every item of that provider's section, or on an individual entry in `getMenus()` to move just that one (its section keeps its other items at the top level). Several providers commonly share one section (e.g. Config/Site/UiBundle all merge into "management"), so an item's own `tier` never drags along another provider's items sharing that same section.

**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, sorted alphabetically. `name` is a route name resolved to its real URL through the app's own router (not EasyAdmin's dashboard routing, so it also works for a route outside the dashboard, e.g. a public page). Use `url` instead for a literal, already-absolute URL — it's used as-is, no route resolution at all, and takes precedence when both are set:

```
'showcase' => [
    'url' => 'https://example.com/showcase',
    'label' => 'label.showcase',
    'translation_domain' => 'my_bundle',
    'icon' => 'fas fa-shapes',
],
```

A few more optional keys: `role` (e.g. `'ROLE_EDITOR'`) hides the link from users lacking it — omit it for links with no access restriction of their own; `target` (e.g. `'_blank'`) is for a link leaving the admin entirely — it gets an external-link glyph automatically, and (for a `name`-based link) resolves to a full absolute URL instead of a relative path; `pinned` (bool) sorts the link after every non-pinned one regardless of its label — ConfigBundle's own "Visit the site" link (using the `site-url`/`site-name` configs) uses it to always stay at the very bottom of the links section; `label_parameters` (array) is passed through to the translator alongside `label`, for a translated label embedding a runtime value (e.g. `['%name%' => $siteName]`) — omit it for a plain translation key with no placeholder, the usual case.

**Guided tour:** any entry in `getMenus()`/`getLinks()` can add an optional `'description'` key — a one-line "what is this for" sentence, same `translation_domain` — to feed the `/management` dashboard's "Guided tour" button. It highlights every described item in turn with a short explanation, matched against the sidebar's own rendered link (see `OnboardingStepBuilder`), so there's nothing else to wire up. It's entirely optional and can be filled in bundle by bundle: an entry without a `description` is simply skipped, it never breaks anything.

Contributing linkable routes for SiteBundle menus
-------------------------------------------------

[](#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 importmap entries from other bundles
-------------------------------------------------

[](#contributing-importmap-entries-from-other-bundles)

If your bundle ships its own Stimulus controller for the `/management` dashboard (or any other AssetMapper entry the consuming app needs in its `importmap.php`), implement `ImportmapProviderInterface` — no manual service tagging needed, same `TaggedInterfacePass` mechanism as `MenuProviderInterface` above.

The interface has two methods, mirroring `c975l/ui-bundle`'s own `BundleScriptAdminProviderInterface`/`BundleScriptProviderInterface` admin/non-admin split: `getAdminImportmapEntries()` for scripts loaded on the `/management` dashboard only, `getImportmapEntries()` for anything else (a front-end Stimulus controller, or any other AssetMapper dependency). Both end up in the same `importmap.php` — the split only matters to keep each entry's purpose explicit at the declaration site. Return `[]` from whichever one doesn't apply.

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Management\ImportmapProviderInterface;

class ImportmapProvider implements ImportmapProviderInterface
{
    // Import name => ['path' => string, 'entrypoint' => bool]. 'path' is relative to the project root, exactly as it should appear in importmap.php
    public function getAdminImportmapEntries(): array
    {
        return [
            '@c975l/my-bundle/controllers-admin.js' => [
                'path' => './vendor/c975l/my-bundle/assets/controllers-admin.js',
                'entrypoint' => true,
            ],
        ];
    }

    public function getImportmapEntries(): array
    {
        return [];
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

Entries contributed this way aren't written to `importmap.php` on their own — nothing hooks into Composer from inside a bundle. Wire the collecting command into each consuming app's `composer.json`, in the same `auto-scripts` block that already runs `importmap:install`:

```
"auto-scripts": {
    "cache:clear": "symfony-cmd",
    "assets:install %PUBLIC_DIR%": "symfony-cmd",
    "importmap:install": "symfony-cmd",
    "c975l:config:check-importmap": "symfony-cmd"
}
```

`c975l:config:check-importmap` then runs on every `composer install`/`composer update`: it adds any entry contributed by an `ImportmapProviderInterface` that's missing from `importmap.php`, and never touches one that's already there (so a manually customized `path` survives). This is a one-time addition per app — after that, a new bundle (or a new provider in an existing one) picks up its `importmap.php` entry on the next `composer update` with no further action.

It also covers the **third-party packages the c975L bundles' own JS imports by bare specifier** — `@symfony/ux-chartjs`, imported by this bundle's `controllers-admin.js` for the health check trend chart, being the one that actually bites. That entry is normally written by the package's own Flex recipe, which doesn't always run; when it's missing, the browser can't resolve the specifier, the **whole module fails**, and every Stimulus controller it was going to register is silently lost — back-office block drag-and-drop and duplication included, with nothing but a console error to show for it. The command scans each installed c975L bundle's `assets/**/*.js`, and for any bare specifier with no entry it resolves the path from the package's own `assets/package.json` (`name` + `main`, the Symfony UX convention) and adds it as a non-entrypoint. A specifier it can't find under `vendor/` is reported instead of guessed at — install the package, or add the entry by hand.

Contributing a sitemap from other bundles
-----------------------------------------

[](#contributing-a-sitemap-from-other-bundles)

If your bundle has public urls of its own (a book catalogue, a shop, a gallery…), implement `SitemapProviderInterface` — no manual service tagging needed, same `TaggedInterfacePass` mechanism as `MenuProviderInterface` above.

`SitemapWriter` then writes one `public/sitemap-.xml` per provider **and** the `public/sitemap-index.xml` declaring them all, so a bundle never renders or writes a sitemap itself, and the consuming app has nothing to list by hand. It runs from the `c975l:sitemaps:create` command (schedule it, see `c975l/site-bundle`'s scheduler section) and from the "Create sitemaps" dashboard shortcut. Both the writer and the two Twig templates live here rather than in SiteBundle, so any combination of bundles gets its sitemaps and its index, SiteBundle installed or not.

Tip

Implementing this interface also gets your urls **health-checked**, at no extra cost: with `c975l/site-bundle` installed, its `DeclaredUrlsHealthCheckPass` registers one health check provider per `SitemapProviderInterface`, under its own `urls-` kind (see [Health check](#health-check) and SiteBundle's own README). Nothing else to implement, and each bundle's urls stay schedulable on their own.

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Management\SitemapProviderInterface;

class MySitemapProvider implements SitemapProviderInterface
{
    // Gives public/sitemap-my-bundle.xml - keep it short and stable, it ends up in a public url
    public function getSitemapName(): string
    {
        return 'my-bundle';
    }

    public function getUrls(): array
    {
        return [[
            'loc' => 'https://example.com/my-thing/some-slug',
            'lastmod' => '2026-07-26',
            'changefreq' => 'monthly',
            'priority' => 8,
        ]];
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

`priority` is an integer on the admin's own `0`-`10` scale (the same one as a page's priority), converted by `SitemapWriter` to the `0.0`-`1.0` the sitemap protocol accepts — so a provider never does that conversion itself. A value outside the scale is bounded, and a missing `lastmod`/`changefreq`/`priority` is defaulted (today, `weekly`, `5`), so an incomplete url degrades instead of producing an invalid sitemap. `getSitemapName()` has to be unique across every installed bundle: two providers sharing it would overwrite each other's file, so it throws a `LogicException` instead.

Return `[]` when there's nothing to declare (a bundle installed but with nothing published yet): no file is written and nothing is added to the index — an indexed empty `urlset` is just a crawl error, and any file left by a previous run is removed so nothing stale keeps being served. Same when `site-url` isn't configured, since a sitemap only accepts absolute urls: no provider can build one, so no index is written either.

Point Google Search Console at `sitemap-index.xml` only, never at the sub-sitemaps — installing or removing a bundle then changes what's crawled with nothing to update on Google's side. Both templates are overridable: `@c975LConfig/sitemaps/sitemap.xml.twig` (a sub-sitemap, gets `urls`) and `@c975LConfig/sitemaps/sitemap-index.xml.twig` (the index, gets `sitemaps`).

Contributing "What's new" entries from other bundles
----------------------------------------------------

[](#contributing-whats-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
------------------------------------------------

[](#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
---------------------------------------------------

[](#contributing-dashboard-shortcuts-from-other-bundles)

The `/management` dashboard shows a grid of quick-action tiles (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(),
                'role' => 'ROLE_SUPER_ADMIN',
                'category' => ShortcutProviderInterface::CATEGORY_MAINTENANCE,
            ],
        ];
    }
}
```

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`:** reflects an on/off state (e.g. a toggled maintenance mode) — one-shot actions with no on/off state can always return `false`. 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"). It carries no styling of its own — every tile looks the same regardless of state, so a tile never reads as "currently pressed".

**`role`:** optional — omit it for a shortcut with no access restriction of its own, set it (e.g. `'ROLE_SUPER_ADMIN'`) to hide the tile from users lacking it.

**`category`:** optional too — one of `ShortcutProviderInterface`'s `CATEGORY_EXPORT`/`CATEGORY_MAINTENANCE`/`CATEGORY_SITE` constants, or a custom `['label' => string, 'translation_domain' => string]` pair. Shortcuts sharing the same category (across bundles) are ordered next to each other in the grid — e.g. every export-related shortcut ends up adjacent — though the grid itself stays a single flat panel with no heading per category. Omit it to fall into the generic "Other" category.

**`method`:** optional, `'POST'` by default. Set it to `'GET'` for the rare tile that opens a page instead of acting — it is then rendered as a plain link, with no form and no CSRF token, and its route must be a regular `GET` page. See `ConfigPruneController::index()`, the "Obsolete configs" listing, for the reference implementation. Anything that changes state stays `POST`.

**Rendering:** shortcuts are merged across every provider and ordered by category then by label by `ShortcutBuilder::getShortcuts()`, then rendered with the shared `templates/management/_shortcuts.html.twig` partial as one flat grid, each tile its own small `` (or an `` for a `GET` one).

Contributing essential actions from other bundles
-------------------------------------------------

[](#contributing-essential-actions-from-other-bundles)

The `/management` dashboard shows an "Essential actions" checklist — not a one-time onboarding wizard, but a permanent quick-access entry point to the handful of settings every site needs, always linking straight to the relevant Config screen so a value can be reviewed or redone at any time.

Satellite bundles contribute their own actions by implementing `EssentialActionProviderInterface` — no manual service tagging needed, `TaggedInterfacePass` auto-detects any class implementing it, same mechanism as `MenuProviderInterface` above:

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Management\EssentialActionProviderInterface;

class MyEssentialActionProvider implements EssentialActionProviderInterface
{
    public function getEssentialActions(): array
    {
        return [
            [
                'slug' => 'my-action',
                'label' => 'label.my_essential_action',
                'description' => 'description.my_essential_action',
                'translation_domain' => 'my_bundle',
                'url' => '/management/my-entity',
                'isDone' => $this->isConfigured(),
                'order' => 50,
            ],
        ];
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

`isDone` only drives the status icon (a checkmark once true) — the link itself is always shown, even once done. `order` decides the checklist's display order across every provider (low to high), unlike menus/alerts which sort alphabetically. `EssentialActionBuilder::getProgress()` (`{done, total}`) drives the panel's "X/Y configured" subtitle.

Contributing dashboard widgets from other bundles
-------------------------------------------------

[](#contributing-dashboard-widgets-from-other-bundles)

Any bundle can render an arbitrary block on the `/management` dashboard (e.g. UiBundle's Donovan card) by implementing `DashboardWidgetProviderInterface` — no manual service tagging needed, `TaggedInterfacePass` auto-detects any class implementing it, same mechanism as `MenuProviderInterface` above:

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Management\DashboardWidgetProviderInterface;

class MyDashboardWidgetProvider implements DashboardWidgetProviderInterface
{
    public function getDashboardWidgets(): array
    {
        if (!$this->isEnabled()) {
            return [];
        }

        return [
            ['template' => '@MyBundle/management/_my_widget.html.twig', 'context' => ['foo' => 'bar']],
        ];
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

The dashboard template only loops and includes each widget's own `template` with its own `context` — it never contains business logic about what a widget is. Return `[]` when there's nothing to show (e.g. an unconfigured feature) so it stays entirely absent rather than showing a disabled placeholder.

Health check
------------

[](#health-check)

`/management/health-check` gives a per-page technical health snapshot of the site — Lighthouse scores, security headers, W3C markup validation, WCAG accessibility issues (whichever `HealthCheckProviderInterface` implementations are installed; `c975l/site-bundle` contributes eleven, see its own README) — without needing Node/Lighthouse-CLI or any other JS tooling: everything runs server-side over plain HTTP calls.

**Reading the table**: the page lists one row per url *and* per kind, its rows grouped by url. The row opening each group carries that page's name and is tinted with the page's own verdict — the worst status among the rows currently listed for it, so a page reads as ok/warning/error at a glance without adding up its rows' own status pills. The verdict follows the table's filters: filtering on a single kind repaints each group with what that kind alone found.

**Refreshing results**: `php bin/console c975l:health-check:run` runs every registered provider and appends their results (never triggers a live check from a page load). It accepts a repeatable `--kind=` option to run only specific providers — e.g. `--kind=wave` on its own, less frequent cron entry for a paid/credit-based provider, separately from the free ones:

```
php bin/console c975l:health-check:run                                    # every provider
php bin/console c975l:health-check:run --kind=pagespeed --kind=w3c        # only these two
```

There's also a **"Run health check now"** button directly on the page. It doesn't run the check in your request: it dispatches one `RunCommandMessage` per registered kind (`c975l:health-check:run --kind=…`, the very command above) and returns immediately. A single provider can hold thousands of urls — a gallery declares one per photo — and a run that times out mid-way persists nothing at all.

This needs `RunCommandMessage` routed to an asynchronous transport, and a worker consuming it:

```
# config/packages/messenger.yaml
framework:
    messenger:
        routing:
            Symfony\Component\Console\Messenger\RunCommandMessage: async
```

```
php bin/console messenger:consume async scheduler_site
```

If it isn't routed, Messenger handles the message synchronously — the button then behaves as it did before, blocking the request. Results appear on the page as each job completes, and `HealthCheckAlertProvider` raises what needs attention (errors, then warnings, with the date of the last run) on the dashboard and on this page, which is what tells you a queued run is done.

**History, not just a snapshot**: every run appends new `HealthCheckResult` rows rather than overwriting — the page itself only shows the latest one per (url, kind), but the full history feeds a trend chart (ok/warning/error counts over time, via `symfony/ux-chartjs` — a regular Composer dependency, Flex wires it up automatically) and an **Export (CSV)** button producing a dated snapshot, useful as an audit-trail artefact (e.g. accessibility declarations). No pruning is done automatically — weekly/monthly runs across a site's pages stay a modest row count for years; add your own cleanup if that assumption stops holding for a particular site.

The table itself can be sorted (click a column) and filtered (free-text search, status, kind) client-side — hand-rolled (`assets/js/health-check-table.js`), no DataTables/jQuery dependency.

The page also shows the same dashboard-wide alerts as `/management` (e.g. a health check provider's own missing API key, flagged via its config's `severity`), so anything blocking a full check is visible without leaving the page.

Contributing health check providers from other bundles
------------------------------------------------------

[](#contributing-health-check-providers-from-other-bundles)

Any bundle can contribute a check by implementing `HealthCheckProviderInterface` — no manual service tagging needed, `TaggedInterfacePass` auto-detects any class implementing it, same mechanism as `MenuProviderInterface` above:

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Entity\HealthCheckResult;
use c975L\ConfigBundle\Management\HealthCheckProviderInterface;

class MyHealthCheckProvider implements HealthCheckProviderInterface
{
    // Stable identifier for this provider's rows (eg. "my-check") - used for --kind= filtering and stored on every HealthCheckResult
    public function getKind(): string
    {
        return 'my-check';
    }

    // One entry per checked url: ['url', 'label', 'status' => HealthCheckResult::STATUS_*, 'summary', 'details' => array, 'editUrl']
    public function runChecks(): array
    {
        return [
            [
                'url' => 'https://example.com/pages/home/',
                'label' => 'Home',
                'status' => HealthCheckResult::STATUS_OK,
                'summary' => 'Everything checks out',
                'details' => null,
                'editUrl' => '/management/my-entity/1/edit',
            ],
        ];
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

**Never call a slow/paid API from a controller** — `runChecks()` is only ever invoked from `c975l:health-check:run` (via `HealthCheckRunner`), so a page load never blocks on it. If your check needs an API key, read it via `ConfigServiceInterface` like any other config (see [Defining config entries for your bundle](#defining-config-entries-for-your-bundle) above) and degrade gracefully without one — either skip entirely (return `[]`) or, if the check is otherwise expected to be configured (see `c975l/site-bundle`'s own PageSpeed/WAVE providers), return a single explanatory row instead of one per page.

`editUrl` is optional (omit or `null` for a row with no admin CRUD counterpart, e.g. a site-wide check) — the admin edit screen for the entity behind that row (e.g. SiteBundle's Page edit screen), shown on the Health check table as a pencil link next to the tested url.

Contributing health check advice from other bundles
---------------------------------------------------

[](#contributing-health-check-advice-from-other-bundles)

Any bundle can attach actionable advice under a Health check table row (e.g. "this page is missing an H1" linking to its edit screen) by implementing `HealthCheckAdviceProviderInterface` — no manual service tagging needed, `TaggedInterfacePass` auto-detects any class implementing it, same mechanism as `MenuProviderInterface` above:

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Entity\HealthCheckResult;
use c975L\ConfigBundle\Management\HealthCheckAdviceBuilder;
use c975L\ConfigBundle\Management\HealthCheckAdviceProviderInterface;

class MyHealthCheckAdviceProvider implements HealthCheckAdviceProviderInterface
{
    // Keyed per result, via HealthCheckAdviceBuilder::key() (only the results this provider actually has something to say about) - $results is the same HealthCheckResult[] the current screen renders (dashboard "Health check" page or a CRUD's own scoped tab)
    public function buildAdvice(array $results): array
    {
        $advice = [];

        foreach ($results as $result) {
            if ('my-check' !== $result->getKind()) {
                continue;
            }

            $advice[HealthCheckAdviceBuilder::key($result)] = [
                [
                    'text' => '3 images are missing an alt text',
                    'url' => '/management/my-entity/1/edit',
                    // Optional - the individual offenders behind that line, rendered as a collapsed list under it
                    'items' => [
                        ['text' => 'banner.jpg', 'url' => '/management/my-entity/1/edit#block-4', 'label' => 'Edit the block'],
                    ],
                ],
            ];
        }

        return $advice;
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

Always build the key with `HealthCheckAdviceBuilder::key()` rather than concatenating it yourself — the table looks each row's advice up under that exact key, and a mismatch shows no advice at all rather than raising an error. Keying by `kind` alone isn't enough: the Health check page lists one row per url *and* per kind.

Each line needs a `text`, and may carry a `url` (rendered as a link next to the text) and an `items` list. `items` is for a line that summarizes several offenders ("3 images are missing an alt text") — each entry needs its own `text`, and may carry a `url` plus the `label` for that link (falling back to a pencil icon alone), so a dozen offenders stay collapsed instead of pushing the following rows off screen.

`HealthCheckAdviceBuilder::build()` merges every registered provider's advice; two providers with something to say about the same result have their lines appended, neither overwrites the other. It's shared by the dashboard "Health check" page and any CRUD's own "Health check" tab (both render through the same `health_check/_table.html.twig`), so advice reads identically everywhere.

Dev profile — automating what the dev toolbar shows
---------------------------------------------------

[](#dev-profile--automating-what-the-dev-toolbar-shows)

`php bin/console c975l:dev-profile:run` renders every page your bundles declare **through the local kernel**, with the profiler on, and prints the list of what the Symfony dev toolbar would flag on each: n+1 queries, deprecations, missing translations, external HTTP calls made while rendering, and so on. It's the automation of "open every page in dev and look at the toolbar".

Everything about it is dev-only: the command, the runner, the collector and every path provider are marked `#[When('dev')]`, so none of those services even exist in prod (where the `profiler` service doesn't either). Nothing is persisted — no entity, no dashboard page, no trend chart. The output *is* the deliverable: a list to fix.

It reads its numbers from the profiler, so `symfony/profiler-pack` has to be installed (it is by default in a `symfony/skeleton` dev environment); without it the command says so on every page rather than reporting them clean.

**Why it doesn't reuse the health check**: [Health check](#health-check) fetches the *live* site over HTTP at `site-url`, which points at production even when run from a dev machine — exactly what you want to judge a deployed site, and exactly what you don't want when profiling the code you're editing. This command never builds a URL at all: providers declare local paths (`/`, `/pages/contact`), each is handed straight to the kernel like a functional test does, so what's measured is your local code against your local database.

```
php bin/console c975l:dev-profile:run                        # every declared page, problems only
php bin/console c975l:dev-profile:run --path=/pages/contact  # one page, repeatable
php bin/console c975l:dev-profile:run --all                  # also list the clean pages, with their numbers
```

Sample output:

```
/ — Accueil
  HTTP 200 · 47 requêtes (31.2 ms) · 68 templates (44.1 ms) · 2 dépréciations · cache 12/40 · 240 ms · 14.2 Mo
  ERREUR Doctrine       31 requêtes identiques répétées (n+1), dont 32 fois : SELECT t0.id FROM site_block t0 WHERE t0.page_id = ?
  ALERTE Dépréciations  2 dépréciation(s) : Since symfony/framework-bundle 7.3: ...

```

The command exits non-zero as soon as one page has an **error**-level offence, so it can gate a pre-push hook the same way `c975l:site:smoke-test` gates a deployment — or your app's `composer test`, as the last entry so it runs once the test suite is green:

```
"scripts": {
    "test": [
        "@php bin/console cache:warmup --env=test",
        "phpunit",
        "@php bin/console cache:pool:clear cache.app --env=dev",
        "@php bin/console c975l:dev-profile:run --env=dev"
    ]
}
```

`--env=dev` is not optional there: the command is `#[When('dev')]`, so it doesn't exist in the `test` environment — and the dev database is the one holding the pages you actually want profiled, where a test database would only hold fixtures.

### What's measured, and what counts as an offence

[](#whats-measured-and-what-counts-as-an-offence)

AreaRead fromReported whenDoctrine`db` collectormore than `MAX_QUERIES` (30) queries — error past 60 — or more than `MAX_DUPLICATE_QUERIES` (2) identical queries repeated, error past 9. The worst offender's SQL is quotedDeprecations`logger` collectorany deprecation (warning) — the cheapest way to see what a Symfony major bump will requireLogs`logger` collectorany error-level log written while renderingTranslations`translation` collectorany key with no translation (error, the keys are listed) or served from the fallback locale (warning)HttpClient`http_client` collector**any** call to an external API while rendering (error): that belongs in a command writing to the database, or at worst behind a cacheTwig`twig` collectormore than `MAX_TEMPLATES` (150) templates rendered — deliberately high, a block-based theme legitimately renders dozens of small templates per pageResponsestatus codea non-200: a redirect is a warning (usually the firewall, nothing was profiled), anything else an errorTimings, memory and cache hits/misses are printed as context but are **never** an offence: `APP_DEBUG`, no opcache and no preloading make a dev machine's milliseconds say nothing about production, and the misses only say how warm the pools happened to be when the run started — whereas the counts above are the same numbers production would produce. The thresholds are constants on `DevProfileAnalyzer` — a site needing different ones overrides that service.

**Clear the app cache pool first** (`php bin/console cache:pool:clear cache.app`). Anything a cached block hides — a missing translation inside it, a Twig syntax error, the queries it would run — stays hidden as long as its cache entry is there, and the run reports the page as clean. It's the single biggest way to get a falsely reassuring report.

Two deliberate behaviours worth knowing: the first declared path is profiled **twice** and its first result dropped (the kernel stays booted from one path to the next, so that one would otherwise carry every warm-up cost — config read from the database, templates compiled, cache pools filled — that none of the following ones show); and `services_resetter` is called after each path, exactly as a messenger worker does between two messages, without which every page would be reported carrying the previous ones' numbers.

Contributing dev profile paths from other bundles
-------------------------------------------------

[](#contributing-dev-profile-paths-from-other-bundles)

Any bundle can declare the pages it owns by implementing `DevProfilePathProviderInterface` — no manual service tagging needed, `TaggedInterfacePass` auto-detects any class implementing it, same mechanism as `MenuProviderInterface` above. Mark it `#[When('dev')]`, so it never reaches a production container:

```
namespace App\Management;

use c975L\ConfigBundle\Management\DevProfilePathProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\When;

#[When('dev')]
class MyDevProfilePathProvider implements DevProfilePathProviderInterface
{
    public function __construct(
        private readonly MyRepository $myRepository,
    ) {
    }

    // One entry per path to profile: ['path' => local absolute path, 'label' => ?string]
    public function getPaths(): array
    {
        $paths = [];
        foreach ($this->myRepository->findAllPublished() as $item) {
            $paths[] = ['path' => '/shop/' . $item->getSlug(), 'label' => $item->getName()];
        }

        return $paths;
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

**Local paths only** — `/pages/contact`, never `https://example.com/pages/contact`: the path is handed to the kernel, no HTTP request and no host involved. Two bundles declaring the same path is fine, it's profiled once. `c975l/site-bundle` already contributes `PageDevProfilePathProvider` (every published `Page`), so an app installing it has nothing to write for its own pages.

Contributing procedures for the dashboard AI assistant
------------------------------------------------------

[](#contributing-procedures-for-the-dashboard-ai-assistant)

`ProcedureProviderInterface` lets a satellite bundle document its own admin workflows (e.g. "how do I create a page") for an AI assistant built into the consuming app's dashboard — ConfigBundle only collects and merges these entries, it doesn't ship the assistant itself.

Satellite bundles contribute procedures by implementing `ProcedureProviderInterface` — no manual service tagging needed, `TaggedInterfacePass` auto-detects any class implementing it, same mechanism as `MenuProviderInterface` above:

```
namespace c975L\MyBundle\Management;

use c975L\ConfigBundle\Management\ProcedureJsonReader;
use c975L\ConfigBundle\Management\ProcedureProviderInterface;

class MyProcedureProvider implements ProcedureProviderInterface
{
    public function getProcedures(): array
    {
        return ProcedureJsonReader::read(\dirname(__DIR__, 2) . '/config/procedures.json');
    }
}
```

Make sure your bundle's `services.yaml` includes the `Management/` folder in its `src/` resource so the class is registered.

Declare your bundle's entries in a `config/procedures.json` file, `slug` unique across every bundle:

```
[
    {
        "slug": "creer-page",
        "title": {
            "en": "Create a page",
            "fr": "Créer une page"
        },
        "body": {
            "en": "Go to Pages, click Add, fill in the title...",
            "fr": "Allez dans Pages, cliquez sur Ajouter, renseignez le titre..."
        }
    }
]
```

**Merging:** `ProcedureBuilder::getAll()` merges every provider's procedures, sorted by `slug` for a stable, deterministic order regardless of service registration order.

Reading config values
---------------------

[](#reading-config-values)

### In PHP

[](#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

[](#in-twig)

```
{# Read from database #}
{{ config('site-name') }}

{# Read from Symfony container parameters #}
{{ configParam('kernel.environment') }}
```

---

Tip

If this project **helps you save development time**:

- [**star** it on GitHub](https://github.com/975L/ConfigBundle) — helps others find it
- [**open an issue**](https://github.com/975L/ConfigBundle/issues/new) to share how you use it — genuinely useful feedback

And if you'd like to support the work directly, the **Sponsor** button at the top of the GitHub page is there for that. Thank you!

License
-------

[](#license)

MIT — see [LICENSE](LICENSE).

###  Health Score

59

—

FairBetter than 98% of packages

Maintenance93

Actively maintained with recent releases

Popularity23

Limited adoption so far

Community20

Small or concentrated contributor base

Maturity85

Battle-tested with a long release history

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~24 days

Recently: every ~0 days

Total

121

Last Release

16d ago

Major Versions

v1.2.2 → v2.02018-08-30

2.x-dev → v3.02022-07-25

1.x-dev → v3.0.22023-05-29

v3.0.2 → v4.02024-01-20

v4.x-dev → v5.02026-06-22

PHP version history (5 changes)v1.0PHP &gt;=5.5.9

v2.2.5PHP ^7

v2.5PHP \*

v3.0PHP ^8

v4.0PHP &gt;=8.0

### Community

Maintainers

![](https://www.gravatar.com/avatar/5679e828a48e37afabd92da60ab8d78bf65a3bedc0f618ef3fddf92082840f52?d=identicon)[Laurent3170](/maintainers/Laurent3170)

---

Top Contributors

[![LaurentMarquet](https://avatars.githubusercontent.com/u/16478286?v=4)](https://github.com/LaurentMarquet "LaurentMarquet (74 commits)")

---

Tags

configsymfonysymfony-bundlesymfonybundleconfig

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/c975l-config-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/c975l-config-bundle/health.svg)](https://phpackages.com/packages/c975l-config-bundle)
```

###  Alternatives

[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k6.0M776](/packages/sylius-sylius)[2lenet/crudit-bundle

The easy like Crud'it Bundle.

1617.3k16](/packages/2lenet-crudit-bundle)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M231](/packages/sulu-sulu)[pimcore/pimcore

Content &amp; Product Management Framework (CMS/PIM/E-Commerce)

3.8k3.9M534](/packages/pimcore-pimcore)[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k18.3M418](/packages/easycorp-easyadmin-bundle)[prestashop/prestashop

PrestaShop is an Open Source e-commerce platform, committed to providing the best shopping cart experience for both merchants and customers.

9.2k19.7k](/packages/prestashop-prestashop)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
