How to combine config schema constraints and form validation

In Drupal 10 and 11 when defining a module configuration, it is expected that a configuration schema/metadata is created. The configuration schema is optional in Drupal 10, if missing it raises a warning in Drupal 11, it will be enforced from Drupal 12.

However it is not a matter of "formality", the configuration schema/metadata play an important role for data integrity, especially on config import and export, but we can leverage it also for feature purpose.

When defining a property in the schema, we have to assign it a type, the allowed types are mapped to the TypedData API. This association is really useful to reduce code boilerplate and avoid duplication.

Let's assume we want to implement some validation to our config form, for example applying a regex to the inputted value. For example:

only_digit:
  type: string
  constraints:
    Regex:
      pattern: '/^\d+$/'
      message: "The %value is not valid. Only digit are accepted."
 

When creating the config form, the constraints are not enforced on front end automatically. This force to create a custom element validation to duplicate the validation in the back end form.

But because these constraints are coming from TypedData API, we can leverage it.

Here an example on how to leverage the constraints on validate a config entity. Note that in this case TypedConfigManager must be Injected to the Entity Form using Dependency Injection.

  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
    parent::validateForm($form, $form_state);

    $entity = $this->buildEntity($form, $form_state);
    // Combine the provided data with the type definitions.
    $typed_config = $this->typedConfigManager->createFromNameAndData(
      $entity->getConfigDependencyName(),
      $entity->toArray(),
    );

    // Validate the input against the define types.
    foreach ($typed_config->validate() as $violation) {
      $form_state->setErrorByName(
        (string) $violation->getPropertyPath(),
        $violation->getMessage(),
      );
    }
  }

And here an example on how to use it for a standard configuration. In this case extending a ConfigFormBase, the TypedConfigManager is already Injected as a depenency

  /**
  * {@inheritdoc}
  */
 public function validateForm(array &$form, FormStateInterface $form_state) {
   parent::validateForm($form, $form_state);
   $config_name = 'module_name.settings';
   // The configuration status get retrieved.
   $data = $this->config($config_name)->get() ?? [];

   // Every configuration value that need validation must be retrieved from the form.
   $data['key'] = $form_state->getValue('key');
   $data['key1'] = $form_state->getValue('key1');
   $data['key2'] = $form_state->getValue('key2');
   
   // Combine the provided data with the type definitions.
   $typed_config = $this->typedConfigManager->createFromNameAndData($config_name, $data);
   
   // Validate against the schema constraints.
   foreach ($typed_config->validate() as $violation) {
     // In this case it is likely we have a nested configuration, so reflect the 
     // form structure.
     $form_state->setErrorByName(
       str_replace('.', '][', (string) $violation->getPropertyPath()),
       $violation->getMessage(),
     );
   }
 }