User-configurable settings in Symfony applications with jbtronics/settings-bundle (Part 1)
Tags: symfony, php, webdev, tutorial
Published: 2024-04-08 09:57:31

Symfony offers vast [configuration possibilities](https://symfony.com/doc/current/configuration.html) using container parameters and environment variables. However, these are only useful for configuration intended to be configured by developers, as they require changing various text files on the server and might even some recompilation of the container (by running a `cache:clear` command).
If you want to allow end-users to configure various aspects of an application, they will need some kind of WebUI to do so (like, for example, WordPress does). However, there is no good way to change Symfony's container parameters or env variables (from various sources) from inside a Symfony application itself.

For this reason, if you want to have user-configurable settings you have to store and work with them independently. For this there are already several Symfony bundles, with the most popular being probably [sherlockode/configuration-bundle](https://github.com/sherlockode/configuration-bundle). However all of these bundles are more or less a simple key-value store, where a configuration can be retrieved via something like `$parameterManager->get('contact_email')`. However this is not really type-safe and DX-friendly, as the return type of the `get` method is not obvious and you have to check yourself which keys are available and auto-complete in common IDEs is not possible.

## Class-based settings

To solve these problems, the MIT-licensed [jbtronics/settings-bundle](https://github.com/jbtronics/settings-bundle) was created. The basic idea is that settings parameters are not organized in a simple key-value storage, but are based around classes. Each (settings) class represents a set of parameters, which are somehow logically connected.


After you have installed the bundle with composer (`composer require jbtronics/settings-bundle`), you can create a new settings class in the `src/Settings` directory of your symfony application.

For example, if you want to allow users to configure the appearance settings of your application, you might create a `AppearanceSettings` class like this:

```php
<?php
// src/Settings/AppearanceSettings.php

namespace App\Settings;

use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Storage\JSONFileStorageAdapter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Validator\Constraints as Assert;

#[Settings(storageAdapter: JSONFileStorageAdapter::class)]
class AppearanceSettings {

    #[SettingsParameter]
    #[Assert\Language]
    public string $language = 'en';

    #[SettingsParameter]
    public bool $darkMode = false;

    #[SettingsParameter]
    #[Assert\Range(min: 12, max: 24)]
    public int $fontSize = 16;

    #[SettingsParameter()]
    public MyTheme $theme = MyTheme::MODERN;
}
```

where `MyTheme` is an enum class like this:

```php
<?php

namespace App\Settings;

use Jbtronics\SettingsBundle\Settings\Enum;

enum MyTheme: string {
    case MODERN = 'modern';
    case TRADITIONAL = 'traditional';
    case FANCY = 'fancy';
}
```

As you can see, the `AppearanceSettings` class is a normal PHP class with some properties. They do not even need to be public, you could also define some getter/setter methods to access them. But we omit that here to make the example more concise.

The only thing special about this class, are the attributes `#[Settings]` and `#[SettingsParameter]`. The `#[Settings]` attribute marks this class as a settings class, which means that it will be automatically registered with the bundle. The `storageAdapter` attribute specified, which storage backend will be saved to actually store the settings. There are multiple storage adapters available, for saving settings in files in various formats or a database. For simplicity, we will use the `JSONFileStorageAdapter` here, which will save the settings in a JSON file in the `var/settings` directory of your Symfony application.

The `#[SettingsParameter]` attribute marks a property as a settings parameter, which means that it will be saved and loaded by the bundle. If you retrieve an instance of the `AppearanceSettings` via the bundle, then these properties are automatically filled with the values from the storage backend.

As many more complex datatypes (objects, etc.) can not be directly saved to the storage backend, the PHP data is normalized/denormalized by so-called `ParameterTypes`, which you can set as an option in the `SettingsParameter` attribute. However, for elementary types like `string`, `int`, `bool`, etc., and even for enums, the correct parameter type is automatically detected by the bundle, based on the type declaration of the property and you don't need to specify it manually.

If you have a more complex datatype, like a custom object, you can easily create your own `ParameterType` class, which implements the `ParameterTypeInterface` and then specify it in the `SettingsParameter` attribute, so that the bundle knows how to normalize/denormalize this datatype.

## Getting current settings

If you wanna read the configured value of a parameter, you can do it by directly accessing the property (or the getter) of the settings object. However, you can not just do a `new AppearanceSettings()`, as this would create a new instance of the class with the default values. Instead, you need to retrieve a settings object from the bundle.

However as the bundle registers all settings classes as services by default, you can simply inject the settings class as a dependency in your controller or service. The bundle will automatically load the settings from the storage and fill the properties of the object with the data. If no settings are found in the storage, then the default values of the properties are used.

```php
<?php 
namespace App\Controller;

use App\Settings\AppearanceSettings;

class MyController extends AbstractController {

    public function index(AppearanceSettings $appearanceSettings) {
        // $appearanceSettings is already filled with the data from the storage
        // To read the values, you can simply access the properties/fields of the object
        $language = $appearanceSettings->language;
        $darkMode = $appearanceSettings->darkMode;
        $fontSize = $appearanceSettings->fontSize;
        $theme = $appearanceSettings->theme;
    }
}
```

The cool thing is, that the properties are type-hinted, so you get auto-completion in your IDE and you can be sure that the value is of the correct type. If you try to set a value of the wrong type, you will get an exception. Also if you retrieve the settings object like this then you have no direct dependency on the settings bundle and you can easily replace the settings instance with a mock in your tests.

If you worry about the performance of loading the settings from the storage every time your services get initialized, you do not have to. The bundle actually replaces the settings object with a lazy proxy, which only really loads the settings from the storage when data is actually accessed. So if your code never actually requires some settings data, then the settings are never loaded from the storage too. 

If you want to use settings in a twig template, you can either pass the settings object to the template or you can use the `settings_instance` twig function defined by the bundle. This function returns the settings object of a given class, which you can then use in your template. The function expects either the fully qualified class name of the settings class, which is not so easy to type in twig, or the so-called short Name of the class. This is by default the class name without the namespace, but you can also specify a custom short name for a settings class by using the `shortName` attribute of the `Settings` attribute.

```twig
{# @var settings \App\Settings\AppearanceSettings #}
{% set settings = settings_instance('\\App\\Settings\\AppearanceSettings') %}
{{ dump(settings) }}

{# or directly #}
{{ settings_instance('appearance').language }}
```

## Changing settings

To change the value of a settings parameter, you can simply set the property of the settings or use any setter method you have defined:

```php
<?php
namespace App\Controller;

use App\Settings\AppearanceSettings;

class MyController extends AbstractController {

    public function changeSettings(AppearanceSettings $appearanceSettings) {
        $appearanceSettings->language = 'de';
        $appearanceSettings->darkMode = true;
        $appearanceSettings->fontSize = 18;
        $appearanceSettings->theme = MyTheme::FANCY;
    }
}
```

All services share the same instance of the settings object, so if you change a value in one service or controller, then it will directly be reflected in all other services and controllers.
So as long as your code always accesses the property or getter of the settings object directly and does not cache the value in a local variable, you can be sure that always the current value is used.

However, this only affects the instance of the settings object in the current request. If you want to persist the changes to the storage, you have to explicitly save the settings object.

## Persisting changes

If you want to interact explicitly with the bundle, then you can use the `SettingsManagerInterface` service. This service allows you to retrieve, save, and reset settings objects.

```php
<?php
namespace App\Controller;

use App\Settings\AppearanceSettings;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;

class MyController extends AbstractController {

    public function changeSettings(SettingsManagerInterface $settingsManager) {
        //Instead of dependency injection, you can also use the get method of the settings manager to retrieve the settings instance
        $appearanceSettings = $settingsManager->get(AppearanceSettings::class);
        
        // Change the settings
        $appearanceSettings->language = 'de';
        $appearanceSettings->darkMode = true;
        $appearanceSettings->fontSize = 18;
        $appearanceSettings->theme = MyTheme::FANCY;

        // Persist the settings to storage
        $settingsManager->save($appearanceSettings);
    }
}
```

Before writing the settings to the storage, the bundle will also validate the settings object, using the Symfony validator component. If the settings object is not valid, according to the defined constraints, then an exception is thrown and the settings are not saved.

If you somehow end up in a situation where you want to discard the changes you made to the settings object, you can simply call the `reload` method of the `SettingsManagerInterface` service, which will reload the settings from the storage and discard all changes you made to the object. Only the parameter properties of the object are reset, the instance itself is not replaced, so that all references to the object in some other places of your code are still valid.

```php
<?php
namespace App\Controller;

use App\Settings\AppearanceSettings;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;

class MyController extends AbstractController {

    public function changeSettings(SettingsManagerInterface $settingsManager) {
        $appearanceSettings = $settingsManager->get(AppearanceSettings::class);
        
        // Change the settings
        $appearanceSettings->language = 'de';
        $appearanceSettings->darkMode = true;
        $appearanceSettings->fontSize = 18;
        $appearanceSettings->theme = MyTheme::FANCY;

        // Discard the changes
        $settingsManager->reload($appearanceSettings);
    }
}
```

If you want to reset the settings to their default values, defined in the code, you can use the `resetToDefaultValues` method of the `SettingsManagerInterface` service. Like the `reload` method, this method will also maintain the instance of the settings object.

```php
<?php
namespace App\Controller;

use App\Settings\AppearanceSettings;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;

class MyController extends AbstractController {

    public function resetSettings(SettingsManagerInterface $settingsManager) {
        $appearanceSettings = $settingsManager->get(AppearanceSettings::class);
        
        // Reset the settings to their default values
        $settingsManager->resetToDefaultValues($appearanceSettings);
    }
}
```

## Profiler

If you are using Symfony profiler, you should be able to see a new "Settings" panel in the profiler. It shows all settings classes with their parameters defined in your application and their resolved metadata (like which storage adapter to use, which parameter types should be used for mapping, etc.). You can also see the current values of the settings objects and their parameters to the time of the request, which is useful for debugging.

## And many more

The bundle has many more features, like automatic form generation for easy changeable settings in the WebUI, easy versioning of settings with migrations, and much more.

These features will be explained more in the next parts of this tutorial. If you wanna check them out now, you can take a look at the [documentation](https://jbtronics.github.io/settings-bundle/) of the bundle, where every feature and option of the bundle is explained in detail.