jbtronics/settings-bundle
Symfony bundle for managing typed application settings with a clean PHP API, Doctrine storage, validation, and DI integration. Define settings as classes, inject them anywhere, and edit values via forms or CLI.
Install with Composer and let Flex register the bundle (check config/bundles.php for Jbtronics\SettingsBundle\JbtronicsSettingsBundle if you're not on Flex):
composer require jbtronics/settings-bundle
v5.0.0 requires PHP 8.2+ and Symfony 7.4+. If you are on PHP 8.1 or an older Symfony (6.4, 7.0–7.3), stay on the 4.x line — pin it explicitly ("jbtronics/settings-bundle": "^4.0") so a composer update doesn't strand you. Check the requirements before your first composer require; the constraint failure is otherwise the first thing you'll debug.
The default storage adapter persists to a database table, so create it before your first read:
php bin/console make:migration # or doctrine:schema:update --force in dev
php bin/console doctrine:migrations:migrate
Define your first settings class as a plain PHP object in src/Settings/. The class-level attribute marks it as a settings group; each property is a setting with a default:
namespace App\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsGroup;
use Jbtronics\SettingsBundle\Settings\SettingProperty;
#[SettingsGroup]
class SiteSettings
{
#[SettingProperty(default: 'My App')]
public string $siteName = 'My App';
#[SettingProperty(default: false)]
public bool $maintenanceMode = false;
#[SettingProperty(default: '[email protected]')]
public string $fromAddress = '[email protected]';
}
Read and write through the manager — inject SettingsManagerInterface (or autowire the concrete SettingsManager) and call get() / save():
$settings = $this->settingsManager->get(SiteSettings::class);
$settings->maintenanceMode = true;
$this->settingsManager->save($settings);
That round trip is the whole package in miniature: defaults live in code, values live in storage, and you always work with a typed object. A good first use case is exactly this — a handful of site-wide toggles or contact details that an admin edits in a form instead of in a YAML file.
Where to look first: the repo docs/ directory (attributes, storage adapters, form integration), then src/Settings/ for the attribute classes and src/Storage/ for adapters. The bundle's own tests are the most reliable reference for edge-case behavior since the ecosystem is small — and with the v5 type-system change, the tests are the fastest way to see the new getPHPType() contract in practice.
Treat settings classes as configuration value objects, not entities. Keep them small and cohesive (MailSettings, SeoSettings, FeatureFlags) rather than one god-object. Nested groups work well for admin UIs — a "Mail" section containing transport, from-address, and retry settings renders as a fieldset and stays independently testable.
Admin form is the canonical workflow. The bundle ships a form type that reflects over the settings class, so you don't hand-write fields:
$form = $this->createForm(SettingsType::class, $settings);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->settingsManager->save($settings);
// redirect + flash
}
Properties you want to hide from the UI can be annotated/omitted; labels, descriptions, and placeholders come from attributes on the property, so put the user-facing copy next to the setting definition rather than in a template. For enums, type the property as a backed enum and the form gets a choice field for free.
If you extend the form or metadata layer, mind the v5 type contract. ParameterMetadata::getPHPType() now returns a symfony/type-info Type object rather than a string. Anything you wrote that reads metadata to pick a form field or a storage cast — custom SettingsType extensions, custom form field guessers, custom storage adapters — needs to work with the object:
$type = $metadata->getPHPType();
// v4-style string comparisons no longer work:
// if ($type === 'array') { ... }
if ($type->isBuiltin()) { /* ... */ } // inspect instead of string-match
$stringified = (string) $type; // when you genuinely need a name
Add symfony/type-info to your own composer.json if you type-hint against its classes directly rather than only passing the value along.
Wrap access for hot paths. get() hydrates the object from storage; calling it inside a loop or per-request-per-service is wasteful. Inject the settings object once into the services that need it (or a thin AppSettings wrapper holding several settings classes resolved in the constructor), and let Symfony's shared services keep it per-request. If a setting changes mid-request you'll want to re-fetch — don't assume live updates.
Console and ops. Use the bundle's commands to inspect and reset stored values when debugging ("why is this still true in prod?"), and add your own command that dumps a settings class as JSON for support tickets. Resetting a class back to code defaults is a one-liner and is the cleanest recovery path after a bad admin edit.
Testing. In config/packages/test/, point the bundle at the JSON/in-memory storage adapter so tests don't need a database, and reset between tests. A useful pattern is a small test helper that constructs a settings object with explicit values and hands it to the manager, then asserts the consuming service behaves correctly — that tests your code, not the bundle's persistence. If you have custom metadata consumers, assert on Type behavior (nested/nullable/collection) rather than on stringified type names, so a future type-info change doesn't silently break your assertions.
Migrating settings over time. Adding a property with a default is safe: old stored payloads load and the new field takes its default. Renaming or retyping is not — plan a small migration (read old rows, map, write new) or keep the old property deprecated until stored data is rewritten. Note that the v5 upgrade is a framework migration and a data migration of the same shape: verify your existing rows still hydrate into the typed properties after the bump, ideally with a command that round-trips every settings class.
ParameterMetadata::getPHPType() returns symfony/type-info Type objects, not strings. Grep for getPHPType and for string comparisons against type names ('string', 'array', 'bool') before upgrading; those are the call sites that will fail at runtime rather than at compile time. Do the grep, run the test suite, then upgrade — not the other way round.bin/console cache:clear before it shows up. If a new field "doesn't work," this is the first thing to try — and it's doubly true right after the v5 bump, where stale warmup caches from 4.x can mask whether the upgrade actually took.#[Assert\...] constraints on the properties and always check $form->isValid() before save(); programmatic writes bypass form validation but should still hit the validator — test that they do.ServiceLocator/#[Autowire(lazy: true)]) for anything touched at boot.Type objects, collection shapes are describable in richer terms — but that also means your guessing logic has more states to handle, so keep custom field mapping small and explicit.composer.json, skim the changelog before upgrading (v5 is a major for a reason), and expect to own your integration code. The upside is that the source is small enough to fully understand in an afternoon, which is rare for a bundle that replaces a lot of hand-rolled settings plumbing.How can I help you explore Laravel packages today?