DEV Community

Jan Böhmer
Jan Böhmer

Posted on • Originally published at github.com

User-configurable settings in Symfony applications with jbtronics/settings-bundle (Part 1)

Symfony offers vast configuration possibilities 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. 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 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
// 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;
}
Enter fullscreen mode Exit fullscreen mode

where MyTheme is an enum class like this:

<?php

namespace App\Settings;

use Jbtronics\SettingsBundle\Settings\Enum;

enum MyTheme: string {
    case MODERN = 'modern';
    case TRADITIONAL = 'traditional';
    case FANCY = 'fancy';
}
Enter fullscreen mode Exit fullscreen mode

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 
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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

{# or directly #}
{{ settings_instance('appearance').language }}
Enter fullscreen mode Exit fullscreen mode

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
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 of the bundle, where every feature and option of the bundle is explained in detail.

Top comments (3)

Collapse
 
gmurambadoro profile image
Gavin Murambadoro

Dude, I just finished implementing my own custom settings service in a project and now I see this. Now I have to undo everything...

Thanks a lot for this...

Collapse
 
slowwie profile image
Michael

But this is still saved with JSON. This means that if columns change or are removed, you have to work manually with migrations. Isn't it better to simply create a table and work consistently using normal database columns?

Collapse
 
jbtronics profile image
Jan Böhmer

If a parameter is added then the defined default is used if not existing in the persisted data yet. If a parameter is removed, then it's persisted value is just ignored and will be removed on the next persist.

If you wanna change the definition of a parameter, then you will need a migrator method for the settings migration, buts thats basically just a one liner in the simplest case ($data['new_name'] = $data['old_name']).
I am planning to implement some extensions to the makerbundle, so that you can automatically generate the required skeleton code.

And if you were doing anything more complex than changing the underlying column name, you would need to edit the auto generated migrations by doctrine anyway (and you need to work with data at an SQL level not PHP level).

The settings migrations of the bundle are run automatically without any user action and are not tied to any storage backend, allow for easy interchangablity of settings data.