Skip to content

Field Validation Patterns

When to Use

When enforcing data integrity constraints on field values, requiring validation beyond basic required/max_length checks, ensuring data quality and business rule compliance.

Decision

If you need... Use... Why
Simple constraints Built-in constraints (NotNull, Length, Range) Symfony validation, well-tested
Custom validation Custom constraint plugin Reusable validation logic
Field-level validation Constraint in propertyDefinitions() Applied automatically on save
Cross-field validation Entity validation constraints Validate multiple fields together
Widget-specific validation #element_validate UI-specific validation feedback

Pattern

Custom validation constraint:

// src/Plugin/Validation/Constraint/ValidEmailDomainConstraint.php
namespace Drupal\my_module\Plugin\Validation\Constraint;

use Symfony\Component\Validator\Constraint;

/**
 * @Constraint(
 *   id = "ValidEmailDomain",
 *   label = @Translation("Valid email domain", context = "Validation"),
 * )
 */
class ValidEmailDomainConstraint extends Constraint {
  public $message = 'The email domain @domain is not allowed.';
  public $allowedDomains = [];
}

Constraint validator:

// src/Plugin/Validation/Constraint/ValidEmailDomainConstraintValidator.php
namespace Drupal\my_module\Plugin\Validation\Constraint;

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

class ValidEmailDomainConstraintValidator extends ConstraintValidator {

  public function validate($value, Constraint $constraint) {
    if (!isset($value)) {
      return;
    }

    $email = $value->value;
    $domain = substr(strrchr($email, "@"), 1);

    if (!in_array($domain, $constraint->allowedDomains)) {
      $this->context->addViolation($constraint->message, [
        '@domain' => $domain,
      ]);
    }
  }
}

Apply constraint to field:

public static function propertyDefinitions(FieldStorageDefinitionInterface $def) {
  $properties['value'] = DataDefinition::create('email')
    ->setLabel(t('Email'))
    ->setRequired(TRUE)
    ->addConstraint('ValidEmailDomain', [
      'allowedDomains' => ['example.com', 'example.org'],
    ]);

  return $properties;
}

Built-in constraints:

$properties['value'] = DataDefinition::create('string')
  ->addConstraint('Length', ['max' => 255])
  ->addConstraint('Regex', ['pattern' => '/^[A-Z0-9-]+$/']);

$properties['age'] = DataDefinition::create('integer')
  ->addConstraint('Range', ['min' => 0, 'max' => 150]);

Reference: /core/lib/Drupal/Core/Validation/Plugin/Validation/Constraint/

Common Mistakes

  • Wrong: Validating in widgets instead of constraints → Right: Validation bypassed via API, imports, migrations
  • Wrong: Not extending ConstraintValidator → Right: Validator won't be discovered
  • Wrong: Missing @Constraint annotation → Right: Constraint not registered
  • Wrong: Hardcoding error messages → Right: Use translatable strings with placeholders
  • Wrong: Validating on display, not storage → Right: Users see errors after save; validate on input
  • Wrong: Not handling NULL values → Right: Check isset() before validation logic

Security: - ALWAYS validate untrusted input with constraints, not widget validation - Widget validation is UI-only. API calls bypass widgets. - Use whitelist validation (allowed values) not blacklist when possible - Sanitize constraint violation messages (use placeholders, not concatenation)

Development Standards: - Place constraints in src/Plugin/Validation/Constraint/ - Use Symfony validation where possible (well-tested, standard) - Document constraint parameters in class property doc blocks - Inject dependencies via create() method in validators - Return early from validate() if value is NULL/empty (unless checking required)

Common Built-in Constraints: - NotNull, NotBlank: Required checks - Length: String length validation - Range: Numeric range validation - Regex: Pattern matching - Email: RFC email validation - Url: URL validation - Count: Array/collection size validation

See Also