Almost 10 years ago, ConfigFormBase was introduced. It has served us well.
Drupal 10 is far more "API-First" than Drupal 8 was. At least as far as content is concerned. In terms of config, it still has a way to go. In particular, validation of configuration is missing: it's impossible to do that using APIs because the validation logic (if any) is hardcoded in form logic.
Configuration forms have been able to use validation constraints since Drupal 8.0.0 … but Drupal core itself has not adopted it yet, and not made it easy for contributed and custom modules to adopt it. That changes now!
Note: this only covers simple configuration, not configuration entities.
Existing (simple) config forms continue to work exactly as-is. Upgrading a ConfigFormBase subclass to use validation constraints brings benefits that make your modules better prepared for the future:
- simplified config forms
- No more validation logic in config forms: those forms become simpler!
- validatable config
- it becomes possible to validate config outside the context of a form — for example using
drush config:inspect(provided by the Configuration Inspector module)
It's optional for now, but will become required at some point in the future, depending on how smooth adoption goes. Minimization of disruption is important!
What does it look like?
In forms that subclass ConfigFormBase, form elements can now have a property called #config_target. This is the simplest way to use it (this example comes from \Drupal\user\AccountSettingsForm):
$form['registration_cancellation']['user_email_verification'] = [
'#type' => 'checkbox',
'#title' => $this->t('Require email verification when a visitor creates an account'),
'#config_target' => 'user.settings:verify_mail',
'#description' => $this->t('New users will be required to validate their email address prior to logging into the site, and will be assigned a system-generated password. With this setting disabled, users will be logged in immediately upon registering, and may select their own passwords during registration.'),
];
This tells the form system to load the element's #default_value from the verify_mail property of the user.settings config. Then, when the form is submitted, the form system will automatically set that config value to whatever was submitted in this form element. (The submitted value will also be used to validate the config against its constraints.)
How to adopt?
- Make sure that the appropriate validation constraints are defined. Let's say for example that
user.settingsis missing some validation constraint:
user.settings: type: config_object label: 'User settings' mapping: anonymous: type: label label: 'Name' + constraints: + NotBlank: [] + Regex: + pattern: '/[[:alnum:][:space:]]+/' + message: 'Using only emojis is not allowed because it may not be supported in all contexts.' - Then, to start using the validation constraints instead of form validation logic (in this case, there was ZERO validation logic! 😱😅), then typically, all that's needed is adding some
#config_targetproperties to your form elements. - And tada: validation constraints are working:
- Bear in mind that none of this will work unless the form class's
buildForm()method callsparent::buildForm()-- usually it's the last thing thebuildForm()method does. - If an element has both a
#config_targetproperty and a#default_value, the#default_valuewill be used when the form is being built. - In some situations, you may want to do some kind of transformation on a config value before it is displayed in a form element, or you might want to transform a submitted value before it is stored in config. An example would be converting a multi-line text field containing a list of email addresses into an array, and vice-versa (for example, this is done in core by
\Drupal\update\UpdateSettingsForm).In a case like that, you need to set
#config_targetdifferently. To use an example fromUpdateSettingsForm:use Drupal\Core\Form\ConfigTarget; $form['update_notify_emails'] = [ '#type' => 'textarea', '#title' => $this->t('Email addresses to notify when updates are available'), '#rows' => 4, '#config_target' => new ConfigTarget( 'update.settings', 'notification.emails', // Converts config value to a form value. fn($value) => implode("\n", $value), // Converts form value to a config value. fn($value) => array_map('trim', explode("\n", trim($value))), ), '#description' => $this->t('Whenever your site checks for available updates and finds new releases, it can notify a list of users via email. Put each address on a separate line. If blank, no emails will be sent.'), ];In this case, the four arguments when creating a
new ConfigTargetare, in order:- The name of the config object to be read from/written to.
- The config property that will be mapped to the form element. (Optionally multiple property paths — see
\Drupal\locale\Form\LocaleSettingsFormin Drupal core for an example.) - Optionally (required if targeting multiple property paths), a
fromConfigcallable can be passed, to transform the value loaded from the config object's property path into a value that the form element can use. - Optionally (required if targeting multiple property paths), a
toConfigcallable can be passed, to transform the submitted form value before it gets set on the config object's property path. This callable receives the submitted value for the form element, as well as theFormStateobject, to allow for conditional decisions. This callable can return three kinds of values:ToConfig::NoOp(to NOT modify the config object)ToConfig::DeleteKey(to delete the key at the property path)- any other value (to set this value at the property path)
If you are using transformation callbacks, any PHP callable can be used including closures, static methods on the form class and PHP built-ins.
Note:
if you choose to use closures in your config targets, then you will lose the ability to have any sufficiently complex AJAX handling on that form (anything that approaches the complexity of a multi-step form).This is mitigatable from two directions:
- Simplify your config forms to not be multi-step
- Rewrite your closure as a static method
- If all the form elements are using
#config_targetyour form can use the\Drupal\Core\Form\RedundantEditableConfigNamesTraitand remove the implementation of::getEditableConfigNames()from your class. - There's one more thing you may want to do for improving usability, if you're editing a
sequencein this config.Such configuration is typically represented by a
<textarea>in the UI, it's important to be able to generate an appropriate single message. By default, a message likeEntry 1: "http://example.com" is not a valid email address. Entry 3: "http://example.com/sdfsdf" is not a valid email address.
is generated:/** * Formats multiple violation messages associated with a single form element. * * Validation constraints only know the internal data structure (the * configuration schema structure), but this need not be a disadvantage: * rather than informing the user some values are wrong, it is possible * guide them directly to the Nth entry in the sequence. * * To further improve the user experience, it is possible to override * method in subclasses to use specific knowledge about the structure of the * form and the nature of the data being validated, to instead generate more * precise and/or shortened violation messages. * * @param string $form_element_name * The form element for which to format multiple violation messages. * @param \Symfony\Component\Validator\ConstraintViolationListInterface $violations * The list of constraint violations that apply to this form element. * * @return \Drupal\Core\StringTranslation\TranslatableMarkup */ protected function formatMultipleViolationsMessage(string $form_element_name, array $violations): TranslatableMarkup { $transformed_message_parts = []; foreach ($violations as $index => $violation) { // Note that `@validation-error-message` (should) already contain a // trailing period, hence it is intentionally absent here. $transformed_message_parts[] = $this->t('Entry @human-index: @validation-error-message', [ // Humans start counting from 1, not 0. '@human-index' => $index + 1, // Translators may not necessarily know what "violation constraint // messages" are, but they definitely know "validation errors". '@validation-error-message' => $violation->getMessage(), ]); } return new TranslatableMarkup(implode("\n", $transformed_message_parts)); }which you can also override, using knowledge about the data being saved & validated, as well as knowledge about the structure of this very form we're adding this method to:
protected function formatMultipleViolationsMessage(string $form_element_name, array $violations): TranslatableMarkup { if ($form_element_name !== 'update_notify_emails') { return parent::formatMultipleViolationsMessage($form_element_name, $violations); } $invalid_email_addresses = []; foreach ($violations as $violation) { $invalid_email_addresses[] = $violation->getInvalidValue(); } return $this->t('%emails are not valid email addresses.', ['%emails' => implode(', ', $invalid_email_addresses)]); }
See #3364506: Add optional validation constraint support to ConfigFormBase for a concrete example.
Comments
Form elements support
Is
#config_targetapplicable on any element of the Form API ?Is it tested against everything such as
text_formatelements ?Out of the box, no. But there
Out of the box, no. But there is an issue for it: https://www.drupal.org/project/drupal/issues/3463868#comment-15902579