User-configurable settings in Symfony applications with jbtronics/settings-bundle (Part 2): Forms

jbtronics

Jan Böhmer

Posted on May 2, 2024

User-configurable settings in Symfony applications with jbtronics/settings-bundle (Part 2): Forms

After the first blog post of this series explained the basic concepts of the settings-bundle, this blog post will show how to use jbtronics/settings-bundle to create WebUI forms
to change the settings.

Creating forms for settings

Remember the appearance settings from the last blog post? We will now create a form to change these settings:


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

To create forms for settings, you can use the SettingsFormFactoryInterface service. Its createSettingsFormBuilder() method takes a settings instance and returns a form builder, containing a form field for each settings parameter. As it is still a form builder you can add your own additional fields or modify the form as you like. For example, you probably want to add a submit button, so that the user can save the settings:

<?php
// src/Controller/SettingsController.php

namespace App\Controller;

use App\Settings\AppearanceSettings;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class SettingsController extends AbstractController
{
    public function __construct(
        private SettingsFormFactoryInterface $settingsFormFactory,
        private SettingsManager $settingsManager)
    {
    }

    public function appearance(Request $request, AppearanceSettings $settings)
    {
        //Create a builder for the settings form
        $builder = $settingsFormFactory->createSettingsFormBuilder($settings)->getForm();

        //Modify the form: Add a submit button, so we can save the form
        $builder->add('submit', SubmitType::class);

        $form = $builder->getForm();
        $form->handleRequest($request);

        //If the form is submitted and valid, save the settings
        if ($form->isSubmitted() && $form->isValid()) {
            $settingsManager->save($settings);
        }

        return $this->render('settings/appearance.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The settings instance is bound to the form so that the form fields are prefilled with the current settings and changes to the form are reflected in the settings instance. If the form was submitted you then just need to call the save() method of the SettingsManager to save the settings to the storage. However this approach of directly using the global settings instance in the controller has it disadvantages, which will be discussed (and solved) later. For now we will just use this approach to keep things simple.

Customizing the form

If you write a twig template, which renders the form and view the result, you will notice that for the $darkMode parameter a Checkbox was chosen, and for the $fontSize parameter a NumberType input field was chosen. This is because settings-bundle detected the type of the parameter based on the property type hint and assigned the BoolType and IntType parameter types to these parameters. These parameter types are not only used to convert the property values to a storable format and vice versa, but they can also make presets for the rendering of the form fields. Not every parameter type needs to do that, but at least the built-in parameters try to make a good assumption of what form type and what form options to use, to get a nice-looking form, without much customizing.

However, in some cases the presets are not what you want and you might want to change the form type and/or form options. You can do this by utilizing the formType and formOptions options on the SettingsParameter attribute:

#[SettingsParameter(formType: LanguageType::class, formOptions: ['choice_self_translation' => true])]
#[Assert\Language]
public string $language = 'en';
Enter fullscreen mode Exit fullscreen mode

If you want to specify the form label and a description of the form field (which will be shown as help text), you can use the label and description options of the attribute.
This also has the advantage, that this allows to utilize this information in other contexts, too.

#[SettingsParameter(label: "Font size", description: "The font size in pixels to use (between 12 and 24)")]
#[Assert\Range(min: 12, max: 24)]
public int $fontSize = 16;
Enter fullscreen mode Exit fullscreen mode

The strings given there are passed to Symfony form component like normal, meaning that these keys also get translated if you use the translator service.

Forms for multiple settings and embedded settings

If you have multiple settings classes, you wanna create a common form for them, you can use the createMultiSettingsFormBuilder() method of the SettingsFormFactoryInterface service,
which takes an array of settings instances and returns a form builder subforms for each settings instance. This sub-form builder then contains a form field for each settings parameter of each settings instance.

However, for more complex setting structures you might want to use a hierarchical organization of settings. This can be achieved by using so-called settings embeds. If you have a settings class you can add a property to it and mark it with the #[EmbeddedSettings] attribute. The settings-bundle will then fill this property with the instance of the other settings class so that you can access the settings of the embedded settings class through the parent settings class. This is useful if you have settings that are only relevant in a certain context or if you want to group settings. If you worry about performance, do not worry. The injecting embedded settings are lazy-loaded, so that they only get initialized when you access them.

<?php

namespace App\Settings;

use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\EbeddedSettings;

#[Settings]
class AppSettings {

        #[SettingsParameter]
        public string $appName = 'My App';

        #[SettingsParameter]
        public string $appVersion = '1.0.0';

        //The instance of the AppearanceSettings class will be injected here by the settings-bundle
        #[EmbeddedSettings]
        public AppearanceSettings $appearanceSettings;

    }
Enter fullscreen mode Exit fullscreen mode

Settings-bundle will automatically detect the class to inject based on the property type hint. If you want to specify the class to inject, you can use the target option of the EmbeddedSettings attribute, but normally everything should be automatically detected. The embedded settings are the same instance as the global settings object, so you can read and modify the settings of the embedded settings class in the same way as the global settings object.

There is no limit on how deep you can nest embedded settings, so you can create complex settings structures with multiple levels of embedded settings. However, you should be careful with this, as it can make the settings structure hard to understand and maintain. But this also allows you to split up your settings for various parts of your application in a way, that each service only gets the settings it requires, while you can still have a common settings object which contains all settings, for easy access and modification by users.

In principle, settings-bundle can even handle circular embedded settings (A embeds B, B embeds A), but you should avoid this, as you can not easily convert such structures to forms.

The methods of the SettingsManager, normally affect the whole cascade of settings, so if you call save(), reload(), etc. on the top level settings object, it will also affect all embedded settings. You can control this behavior via the cascade parameter of the methods of the SettingsManager. If you set it to false, the method will only affect the settings object you called the method on.

If you call the createSettingsFormBuilder method on a settings object with embedded settings, the return form builder will contain the nested structure of the settings. For each embedded settings object, a subform will be created, allowing you to easily create forms for complex settings structures.

Only showing a subset of settings in a form

Maybe you sometimes want to only show some settings parameters in a form, not all. For example, if you wanna offer a "simple" settings form, where more advanced settings are hidden.
You can achieve this by using the groups option on the #[SettingsParameter] (and #[EmbeddedSettings]) attributes. These groups work very similar to the groups of the symfony/serializer group. You can specify a list of groups a parameter belongs to and then you can specify the groups you want to include in the form builder. On #[Settings] attribute you can specify the default groups for a settings class when it has no explicitly specified groups.

<?php

namespace App\Settings;

use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;

#[Settings(groups: ['simple', 'advanced'])]
class SecuritySettings {
    #[SettingsParameter(groups: ["advanced"])] //Override the default groups of the settings class
    public string $secret = 'mysecret';

    #[SettingsParameter(groups: ["simple", "advanced"])]
    public bool $enforce2FA = false;

    #[SettingsParameter] //This parameter will be in the "simple" and "advanced" group, as inherited from default
    public bool $limitLoginAttempts = true;
}
Enter fullscreen mode Exit fullscreen mode

If you now call the createSettingsFormBuilder with the groups option, to specify which groups should be included. By default (if the value is null), then all groups are included.


$settings = $this->settingsManager->get(SecuritySettings::class);

//This form builder includes all parameters of the SecuritySettings class
$builder = $settingsFormFactory->createSettingsFormBuilder($settings);

//This form builder only includes the parameters of the "simple" group
//Therefore the "secret" parameter is not included
$builder = $settingsFormFactory->createSettingsFormBuilder($settings, ['simple']);
Enter fullscreen mode Exit fullscreen mode

The groups option is an OR condition, so a parameter is shown if it is in at least one of the specified groups. Embedded settings are also only shown if they are in at least one of the specified groups (you can specify the groups of embedded settings in the same way as for normal settings).

Working with temporary copies of settings

One big problem here is how Symfony Forms work: They apply the changes directly to the object you pass to them and do the validation afterward. And even if the validation fails, the changes are still there in the object for the remainder of the request. This is a problem if other parts of your application depend on the settings object, as they will see the changes,
even if the validation failed. Depending on your application, invalid values can cause undesired behavior or exceptions.

To fix this problem, you can retrieve a temporary copy of a settings object, which is completely independent of the global settings object. Therefore the form can modify it as it wants
without affecting the global settings object with invalid values. You can create it by calling the createTemporaryCopy() method on the SettingsManager service. The settings manager creates a new instance and copies all parameter values over to it. If you have embedded settings, the embedded settings are also copied, so that you get a complete deep copy of the settings object, containing the old values.

To save the changes to storage, you first need to merge the temporary copy back to the global settings object. This can be done by calling the mergeTemporaryCopy() method on the SettingsManager service. It will be verified that the temporary copy is valid and otherwise an exception is thrown so that no invalid data can be merged back. All parameter values of the temporary copy are then copied back to the global settings object so that the changes can be saved to storage. If you have embedded settings, the embedded settings are also merged back by default, you can control this behavior with the cascade option, however.

<?php
// src/Controller/SettingsController.php

namespace App\Controller;

use App\Settings\AppearanceSettings;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class SettingsController extends AbstractController
{
    public function __construct(
        private SettingsFormFactoryInterface $settingsFormFactory,
        private SettingsManager $settingsManager)
    {
    }

    public function appearance(Request $request)
    {
        //Create a temporary copy of the settings
        $clone = $settingsManager->createTemporaryCopy(AppearanceSettings::class);

        //Create a builder for the settings form
        $builder = $settingsFormFactory->createSettingsFormBuilder($clone)->getForm();

        //Modify the form: Add a submit button, so we can save the form
        $builder->add('submit', SubmitType::class);

        $form = $builder->getForm();
        $form->handleRequest($request);

        //If the form is submitted and valid, save the settings
        if ($form->isSubmitted() && $form->isValid()) {
            //Merge the temporary copy back to the global settings object, so that we can save the changes
            $settingsManager->mergeTemporaryCopy($clone);
            $settingsManager->save();
        }

        return $this->render('settings/appearance.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

If you do not use "simple" data types like strings, bools, enums, etc. as settings parameters, but more complex objects, which are not easily cloneable, you maybe need to implement
some custom copy and merge behavior for the settings object. You can do this by implementing the CloneAndMergeAwareSettingsInterface interface on your settings class. See the documentation for more information.

Conclusion

You can see that jbtronics/settings-bundle allows you to easily build settings forms, even for complex use cases. You can find more information about form generation and some more
advanced things, like how to implement your own settings parameter types with form presets, in the documentation.

There you can also already find the documentation about more advanced features like settings versioning/migrations and how to combine settings with environment variables for easy automatic deployment of your application. That will be the topic of the next blog post of this series.

💖 💪 🙅 🚩
jbtronics
Jan Böhmer

Posted on May 2, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related