Drupal 9: Using Validation Constraints To Provide Custom Field Validations

Drupal 9: Using Validation Constraints To Provide Custom Field Validations

25th September 2022 - 14 minutes read time

Client requirements can be complex and those complex requirements often require custom code to be written. This includes making sure that the editing process conforms to certain validations.

Drupal can easily handle simple validation like having a value in the field or making sure an email is valid, but with more complex validations usually require custom code.

Whilst it is possible to inject custom validators into form submissions, I find using validation constraint classes makes the whole process much more predicable. Also, validation constraints are applied at a lower level than form validations, which means we can validate the data is correct even if we are creating the entity from an API.

In this article I will look at creating a custom validation constraint that can be used on a field to provide custom validation for certain fields. We will also look at how we can use unit testing to ensure these custom validation constraint classes do what we expect them to do.

Creating A Validation Constraint

A validation constraint is essentially a pair of classes that work together to validate something. One class is constraint plugin that we register with Drupal, and the other is a validator that we add our custom validation code to.

First, let's create the constraint. This class needs to live in the directory src/Plugin/Validation/Constraint in your module directory and must extend the Symfony\Component\Validator\Constraint class.

The definition of the class is pretty simple, you just need to add an annotation to the class to let Drupal know that this is a constraint. It is also quite common to add error messages that we can generate from the validation class.

The following MinimumItemNumberConstraint validation constraint plugin will be used to validate the number of items allowed in the field we are validating. It defines the plugin ID, the message we will print out if there aren't enough items in the field and the number of items that the field must contain.

Also note that the validation message contains a replacement item (in the form of "@count") so that we can customise the error message produced.

<?php

namespace Drupal\my_module\Plugin\Validation\Constraint;

use Symfony\Component\Validator\Constraint;

/**
 * Minimum number of items validation constraint class.
 *
 * @Constraint(
 *   id = "minimum_item_number",
 *   label = @Translation("Minimum number of items", context="Validation"),
 * )
 */
class MinimumItemNumberConstraint extends Constraint {

  /**
   * The message that will appear if there aren't enough items.
   *
   * @var string
   */
  public $notEnoughItems = 'You have only added @count items, please add more.';

  /**
   * The minimum number of items.
   *
   * @var int
   */
  public $count = 1;

}

The other part of the validation constraint is the class that will handle the validation. This needs to extend Symfony\Component\Validator\ConstraintValidator and implement a validate() method, which is where there validation code will be stored.

By default, Drupal uses the name of the plugin class to look for the validator class by taking the name and adding "Validator" to the end. Since we called the plugin class MinimumItemNumberConstraint the validator class must therefore be called MinimumItemNumberConstraintValidator and be located in the same directory as the plugin class.

You can override this behaviour by overriding the validatedBy() method in the constraint plugin class, which must return the fully qualified name of the validator class. Doing this allows you to place your validator in a different location, but this article will focus on the default behaviour.

The validate() method is called automatically by Drupal and is passed the data to validate and the constraint plugin object.

What data we validate depends on what we attach this validation constraint to. As we are adding this validation constraint to a field we then use the typed data API to get the contents of the field and then ensure that the field contains the correct number of items.

The "count" property set in the MinimumItemNumberConstraint class is used here to control how many items that count is set to.

<?php

namespace Drupal\my_module\Plugin\Validation\Constraint;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
 * Validates the number of items in a field.
 */
class MinimumItemNumberConstraintValidator extends ConstraintValidator {

  /**
   * {@inheritdoc}
   */
  public function validate($value, Constraint $constraint) {
    $field_value = $value->getValue();

    if (count($field_value) <= $constraint->count) {
      // We don't have enough items, so add a validation violation.
      $this->context->addViolation($constraint->notEnoughItems, ['@count' => count($field_value)]);
    }
  }

}

As you can see here, the actual validation code is pretty simple, we are just looking at the number of items available and validating against that. You can implement much more complex rules here and validate whatever business rules you need to.

With those two classes in place we can now add our field validation constraint to the field.

Using The Validation Constraint

The validation constraint is used by adding it to the entity or field definition we want to use it for. In other words, as the validation happens at the entity or field level and not the form we need to inject the validation into the definition of those items in order to use them.

The simplest way to do this is to add the constraint to the field (or fields) you want to validate. This can be done using the hook_entity_bundle_field_info_alter() hook, and using the addConstraint() method on the field item.

The following example the minimum_item_number validation constraint we created above is being added to the "field_article_tags" field of the "article" content type.

/**
 * Implements hook_entity_bundle_field_info_alter().
 */
function my_module_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
  if ($bundle === 'article') {
    if (isset($fields['field_article_tags'])) {
      $fields['field_article_tags']->addConstraint('minimum_item_number', ['count' => 3]);
    }
  }
}

We also pass in an array of arguments to the addConstraint() method, which overrides the default $count property in the MinimumItemNumberConstraint class. By setting the properties like this we can set different amounts to different implementations of the validation constraint.

We can also add the constraint to any node in the Drupal install using the hook_entity_type_alter() hook. This works in the same way, but in this case we add the constraint to the node entity itself.

/**
 * Implements hook_entity_type_alter().
 */
function my_module_entity_type_alter(array &$entity_types) {
  $entity_types['node']->addConstraint('minimum_item_number', ['count' => 3]);

The difference here is that the $value parameter in the validate() method now contains the entity object, instead of the field object. This means we just need to do a little bit more checking to ensure that we have the correct sort of entity before running the validation.

// ...
  public function validate($value, Constraint $constraint) {
    if (!$value->hasField('field_article_tags')) {
      return;
    }

    $field_value = $value->field_article_tags->getValue();

    if (count($field_value) <= $constraint->count) {
      // We don't have enough items, so add a validation violation.
      $this->context->addViolation($constraint->notEnoughItems, ['@count' => count($field_value)]);
    }
  }
// ...

Performing entity level validation on a single field is a bit redundant. If, however, we wanted to validate an entity across multiple fields then this would be a good mechanism to do that since we have access to all the data the node contains before it reaches the database.

Note that if you are defining an entity and the fields in a module then constraints are added in a slightly different way. This article will not be covering that, but let us know if you want more information about this topic.

With the validation constraint added to the field (or entity) then the validation will be run whenever data is saved to the entity. This means that the validation is run if the entity is created (or updated) from an API endpoint, through the node edit form, or even through some other means.

When a user attempts to save an article page through the node edit form and they hit the validation they will see the error message returned from the validate() method printed out in the form itself.

Testing The Validation Constraint

The best thing about using validation constraint classes like this is that we can provide unit tests, which allows for a robust validation system.

Within the my_module module we create a test class called MinimumItemNumberConstraintValidatorTest in the directory tests/src/Unit/Plugin/Validation/Constraint. This class contains a single test method, but we use a data provider to inject "valid" and "invalid" data into it.

This is a rare instance of an if statement being used in a test to direct what sort of test to be run, but the mechanism appears to be fairly common in Drupal unit tests. It allows for lots of different data sets to be created by a single data provider and pass through a single test method.

Here is the test class in full.

<?php

namespace Drupal\Tests\my_module\Unit\Plugin\Validation\Constraint;

use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemList;
use Drupal\my_module\Plugin\Validation\Constraint\MinimumItemNumberConstraint;
use Drupal\my_module\Plugin\Validation\Constraint\MinimumItemNumberConstraintValidator;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

/**
 * @coversDefaultClass \Drupal\my_module\Plugin\Validation\Constraint\MinimumItemNumberConstraintValidator
 */
class MinimumItemNumberConstraintValidatorTest extends UnitTestCase {

  /**
   * @covers ::validate
   *
   * @dataProvider providerValidate
   */
  public function testValidate($value, $valid) {
    $context = $this->createMock(ExecutionContextInterface::class);

    if ($valid) {
      $context->expects($this->never())
        ->method('addViolation');
    }
    else {
      $context->expects($this->once())
        ->method('addViolation');
    }

    $constraint = new MinimumItemNumberConstraint();

    $validator = new MinimumItemNumberConstraintValidator();
    $validator->initialize($context);
    $validator->validate($value, $constraint);
  }

  /**
   * Data provider for ::testValidate.
   */
  public function providerValidate() {
    $data = [];

    $field_definition = $this->createMock(FieldDefinitionInterface::class);

    // Validation passes.
    /** @var \Drupal\Core\Field\FieldItemList|\PHPUnit\Framework\MockObject\MockObject $field_list */
    $field_list = $this->getMockBuilder(FieldItemList::class)
      ->onlyMethods(['getValue'])
      ->setConstructorArgs([$field_definition])
      ->getMock();
    $field_list->expects($this->any())
      ->method('getValue')
      ->willReturn([1, 2, 3, 4]);
    $data[] = [$field_list, TRUE];

    // Validation fails.
    /** @var \Drupal\Core\Field\FieldItemList|\PHPUnit\Framework\MockObject\MockObject $field_list */
    $field_list = $this->getMockBuilder(FieldItemList::class)
      ->onlyMethods(['getValue'])
      ->setConstructorArgs([$field_definition])
      ->getMock();
    $field_list->expects($this->any())
      ->method('getValue')
      ->willReturn([1, 2]);
    $data[] = [$field_list, FALSE];

    return $data;
  }

}

There is a fair amount of mocking going on in this class as we need to set up a FieldItemList object and pass this as the $value parameter of the validate() method. With this test in place, however, we are certain that our validation is working correctly in a number of different situations.

Using Services In Validation

If you want to use any services in the validation process then the constraint validator class is where they are added. You just need to implement the Drupal\Core\DependencyInjection\ContainerInjectionInterface interface and add the create() and __construct() methods to the class.

Let's say that we wanted to use the entity_type.manager service to load something from the database. The validation class would therefore need to extend the ContainerInjectionInterface and use the create()/__construct() methods to inject the entity_type.manager service into the class.

<?php

namespace Drupal\my_module\Plugin\Validation\Constraint;

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
 * Validates something using the entity_type.manager service.
 */
class AnExampleConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Constructs an EntityUntranslatableFieldsConstraintValidator object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('entity_type.manager')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function validate($value, Constraint $constraint) {
    // Add validation code here
  }

}

The validate() method can then freely use the enttiy_type.manager service to check the values of this field against the database. Remember that the field currently being validated has not yet been written to the database, but if other fields have been saved then we can use those values to check, for example, that this field is unique.

This gives us the flexibility to abstract some of the logic away from the validation constraint and into separate services.

Comments

Permalink

Hi miststudent2011,

Thanks for reading!

You are right in that you can use this for paragraphs, or any fieldable entity you want to add validation to. You just need to construct your hook_entity_bundle_field_info_alter() hook correctly to look for the entity/field combination you need.

Permalink

Hi, Nice read-up. Thanks for providing this nice piece of content here.

Navya (Fri, 09/30/2022 - 13:59)

Permalink

Very interesting article, one question, is it possible to do the same with Configuration Entities?

I have tried with the Simplenews module to add a validation when creating a mailing list and the validator does not get executed.

Example:

function MY_MODULE_entity_type_alter(array &$entity_types) {
  if (isset($entity_types['simplenews_newsletter'])) {
    $entity_types['simplenews_newsletter']->addConstraint('my_constraint', ['count' => 50]);
  }
}

 

Gonzalo Guevara (Fri, 11/04/2022 - 15:54)

Add new comment

The content of this field is kept private and will not be shown publicly.