Entity validation in Drupal 8 - part 3

We've seen how validation works and how to create a custom validation component previously. Chances are, a validation component already exists for most of the requirements. Thanks to Composer and the way Symfony is organized as components, it is easy to reuse existing components. We will try our hand at one such component, the Zip Code validator.

We will use this validator for one of our text fields. In order to do this, we have to first add this validator to our composer.json file. Here's how our top level composer.json require section looks before adding any external library.

// ...
"require": {
    "composer/installers": "^1.0.21",
    "wikimedia/composer-merge-plugin": "~1.3"
},

We add zip code validator component as a dependency to our Drupal setup using this command, as mentioned in the README.

$ composer require barbieswimcrew/zip-code-validator

After the library is installed successfully, here's how the updated composer.json looks like.

"require": {
    "composer/installers": "^1.0.21",
    "wikimedia/composer-merge-plugin": "~1.3",
    "barbieswimcrew/zip-code-validator": "^1.0"
},

We can't use a Symfony validation component by directly invoking it. So, we have to do a little prepping to bring it into Drupal's context. The first thing we do towards this direction is to convert zip code validation into a Drupal plugin, similar to the ShirtSize plugin we created previously.

namespace Drupal\custom_validation\Plugin\Validation\Constraint;

use ZipCodeValidator\Constraints\ZipCode;

/**
 * Zipcode constraint.
 *
 * An example of using an external validation component for zip codes.
 *
 * @Constraint(
 *   id = "ZipCode",
 *   label = @Translation("Zipcode constraint", context = "Validation")
 * )
 */
class ZipCodeConstraint extends ZipCode {

  public $message = 'This zip code is not valid.';

  /**
   * {@inheritdoc}
   */
  public function validatedBy() {
    return '\ZipCodeValidator\Constraints\ZipCodeValidator';
  }

}

This is just a thin layer on top of our existing validator, just to "drupalize" our validator. Now, we are ready to use it on our entity fields. Assuming you have a plain text field that goes by the machine name field_zip_code, here's how you would use the zip code validator.

/**
 * Implements hook_entity_bundle_field_info_alter().
 */
function custom_validation_entity_bundle_field_info_alter(&$fields, \Drupal\Core\Entity\EntityTypeInterface $entity_type, $bundle) {
  if ($entity_type->id() == 'node' && $bundle == 'article') {
    $fields['field_zip_code']->addConstraint('ZipCode', ['iso' => 'IN']);
  }
}

The iso parameter is the country for which the zip code needs to be validated, which is India in this case. The validator supports lot of countries which you can try out.

Let's take this new field validation for a spin.

use Drupal\node\Entity\Node;

$node = Node::create([ 'title' => 'New article with an invalid zip code', 'type' => 'article']);
$node->uid = 1;
$node->field_zip_code = 'foo'; // bad zip code
$violations = $node->validate();
if ($violations->count() > 0) {
  foreach($violations as $violation) {
    print_r($violation->getMessage()->render());
    print("\n");
    print_r($violation->getPropertyPath());
    print("\n");
  }
 } else {
  $node->save();
}

And our validator rings the alarm.

$ drush @dev scr f.php
[warning] preg_match() expects parameter 2 to be string, object given ZipCodeValidator.php:254
This zip code is not valid.
field_zip_code

Sounds good. Wait, what's that warning about preg_match? Its got to do with mismatch between Symfony's entity validation and Drupal's validation mechanism. Here's how the validation code looks like inside Zipcode's validator:

if (!preg_match("/^{$pattern}$/", $value, $matches)) {
  // ...
}

Here, $value is expected to be a string but Drupal passes a field object to the validate function, thus making the pregmatch fail for all cases. We can address this by overriding the Validator. Let's write a Drupal layer over the existing validator which strips the bare value from the field in question and pass it to the parent validator.

namespace Drupal\custom_validation\Plugin\Validation\Constraint;

use Symfony\Component\Validator\Constraint;
use ZipCodeValidator\Constraints\ZipCodeValidator;
/**
 * Validates zip codes.
 */
class ZipCodeConstraintValidator extends ZipCodeValidator {

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

The naming convention for validators is the 'Validator' string suffixed to the constraint class name, so we can drop the validatedBy function from ZipCodeConstraint. Run this new validator to get correct results this time.

$node = Node::create([ 'title' => 'New article - clean', 'type' => 'article']);
$node->uid = 1;
// make sure you give a correct zip code according
// to the country you specified in the validation constraint
$node->field_zip_code = '600044';
$violations = $node->validate();
if ($violations->count() > 0) {
  foreach($violations as $violation) {
    print_r($violation->getMessage()->render());
    print("\n");
    print_r($violation->getPropertyPath());
    print("\n");
  }
 } else {
  $node->save();
 }

As noted in the previous article, this validation constraint works even via the UI.

Zip code validation

The code for this post can be checked out from the external-validator tag of the custom validation module repo.

$ git clone git@github.com:drupal8book/custom_validation.git
$ cd custom_validation
$ git checkout -f external-validator