Drupalwoo ☉

Creating a Custom Drupal Constraint for Field Validation

Drupal’s field validation system provides a reliable place to enforce rules before content is saved. When built-in constraints such as NotNull, Length, or Regex cannot express a business requirement, a custom constraint lets you keep that rule reusable, testable, and close to the data model.

A custom validation constraint is useful for more than checking whether a value “looks right”. It can validate an Australian postcode against a selected state, reject an invalid reference code, enforce a date relationship, or prevent duplicate values according to a project-specific rule. The same logic can then apply through forms, REST requests, migrations, and programmatic entity saves.

The implementation uses two PHP classes: a constraint definition and a constraint validator. The definition describes the plugin and its error message, while the validator receives the field value and reports violations through Drupal’s validation context. A third step attaches the constraint to the required field.

This approach suits Drupal 10 and Drupal 11 projects, although the exact discovery syntax can vary slightly with the supported core versions. The examples below use a small custom module called site_rules, with a postcode-style rule that can be adapted to many other fields.

Choosing The Right Validation Layer

Drupal has several places where developers can check input, and selecting the correct layer matters. A form alter can display a useful message for one specific form, but it will not necessarily protect an entity saved through JSON:API, a migration, Drush, or custom PHP code. Entity validation is generally the stronger choice when the rule belongs to the field’s data.

A field constraint should describe a property of the value itself. For example, a constraint can require an Australian postcode to contain four digits, or require a booking end date to occur after its start date. A rule that depends on the current user, an external service, or a workflow transition may belong in a custom entity validation handler or application service instead.

Avoid using validation to silently transform data. Trimming whitespace, normalising case, or converting a phone number into a canonical format is better handled during input processing or normalisation. Validation should decide whether a value is acceptable and return a clear message when it is not.

For an Australian site, local requirements often expose weaknesses in simplistic rules. A postcode such as 2000 may be valid for Sydney while a different state selection is not. Suburbs can share names across cities, and addresses may include unit numbers, rural delivery details, or long organisation names. A robust constraint should enforce the actual project requirement rather than assume every address follows one narrow pattern.

Building The Constraint Plugin

Create the following directory structure inside the custom module:

site_rules/
├── site_rules.info.yml
└── src/
    └── Plugin/
        └── Validation/
            └── Constraint/
                ├── AustralianPostcode.php
                └── AustralianPostcodeValidator.php

The constraint class declares its plugin ID, label, supported value type, and default message. The type value is useful documentation and can help Drupal understand which data the constraint is intended to handle. The message is defined on the constraint rather than buried in the validator so that it can be overridden when the constraint is reused.

<?php

namespace Drupal\site_rules\Plugin\Validation\Constraint;

use Symfony\Component\Validator\Constraint;

/**
 * Checks an Australian postcode.
 *
 * @Constraint(
 *   id = "AustralianPostcode",
 *   label = @Translation("Australian postcode"),
 *   type = "string"
 * )
 */
class AustralianPostcode extends Constraint {

  /**
   * The validation error shown to users.
   *
   * @var string
   */
  public $invalidPostcode = 'Enter a valid four-digit Australian postcode.';

}

The validator receives the value through $value. For a field item list, Drupal may pass a list rather than a plain scalar, so the validator should account for the expected shape. A field-level constraint attached to a text field commonly receives the field item value as a string, but checking for an empty value is still important. Empty-value handling normally belongs to the Required or NotBlank constraint, so this validator can allow an empty value and focus on its own rule.

<?php

namespace Drupal\site_rules\Plugin\Validation\Constraint;

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

class AustralianPostcodeValidator extends ConstraintValidator {

  /**
   * {@inheritdoc}
   */
  public function validate($value, Constraint $constraint): void {
    if ($value === NULL || $value === '') {
      return;
    }

    $postcode = trim((string) $value);

    if (!preg_match('/^\d{4}$/', $postcode)) {
      $this->context->addViolation($constraint->invalidPostcode);
    }
  }

}

The regular expression confirms four ASCII digits, but it does not claim that every possible four-digit number is a real postcode. If the project needs a narrower range, replace the expression with a maintained list or a service-based lookup. Do not hard-code an enormous postcode catalogue into a validator unless the data has a clear owner and update process.

Attaching The Rule To A Field

A custom constraint is inactive until it is attached to an entity field. For a field definition supplied by a custom module, add the constraint when the field is created. For an existing configurable field, an alter hook can modify the bundle field definition before Drupal uses it.

The following example applies the rule to field_postcode on nodes of the business bundle:

<?php

use Drupal\Core\Entity\EntityTypeInterface;

/**
 * Implements hook_entity_bundle_field_info_alter().
 */
function site_rules_entity_bundle_field_info_alter(
  array &$fields,
  EntityTypeInterface $entity_type,
  $bundle
): void {
  if ($entity_type->id() === 'node'
    && $bundle === 'business'
    && isset($fields['field_postcode'])) {
    $fields['field_postcode']->addConstraint('AustralianPostcode');
  }
}

Clear Drupal’s caches after adding or changing plugin discovery code. Depending on the field type and Drupal version, an existing field definition may also need to be updated through configuration deployment or a module update hook. Test the field through the actual editorial form rather than assuming that a cache rebuild alone proves the constraint is active.

A custom field type can add constraints directly in its propertyDefinitions() or field-item definition, which is preferable when every instance of that field must obey the same rule. A bundle-specific alter is more suitable when the requirement applies only to one content model. This distinction prevents a postcode rule for business locations from accidentally affecting unrelated profile or event fields.

The validation error is rendered by Drupal’s normal field error handling, so editors receive feedback beside the relevant field. When the same entity is submitted through an API or saved programmatically, the violation remains available to the caller. This gives the rule consistent behaviour across a Drupal editorial workflow, an import from an external CRM, or a migration from a home cooking archive.

Approach Best Use Reusable Across Entity Saves Typical Limitation
Form validation A message specific to one form No Can be bypassed by API or code
Field constraint A rule belonging to field data Yes Needs careful attachment and testing
Entity validation handler Rules involving several fields Yes More code and broader responsibility
Submit-time sanitising Normalising or transforming input Not validation by itself Can hide invalid source data
External lookup service Frequently changing reference data Yes, if integrated well Adds latency and failure handling

Supporting Context And Multiple Fields

Many useful rules require context beyond one field. A postcode alone can be syntactically valid while being incompatible with a selected state. A start date can be correctly formatted but still occur after an end date. These cases require either a constraint attached to the entity or a validator that can reach the containing entity through the validation context.

For a multi-field rule, consider an entity-level constraint rather than forcing a field validator to inspect unrelated properties. An entity constraint receives the complete entity, making the dependency explicit and allowing one violation to be attached to a named property path. This produces clearer code and avoids surprising behaviour when the field is reused elsewhere.

A field constraint can still accept options. For example, the constraint definition could declare a state_field property and use it to identify a related field. Options are passed when the constraint is attached:

$fields['field_postcode']->addConstraint('AustralianPostcode', [
  'state_field' => 'field_state',
]);

The validator can read those options from $constraint->state_field, but it should handle missing fields safely. It should also avoid making network requests during ordinary validation. If a postcode database or address service is required, cache results, define a failure policy, and make the service dependency explicit.

The Australian context is especially relevant when designing address validation. A national retailer may collect state abbreviations, suburb names, and postcodes from customers in Perth, Brisbane, Melbourne, or regional towns. Australia Post conventions can inform the interface, but they do not eliminate the need to allow legitimate address variations. Validation should support the business transaction rather than reject customers because an address contains an uncommon locality format.

Testing And Maintaining The Implementation

A constraint should have automated tests for valid, invalid, empty, and unexpected values. Drupal’s kernel test environment is useful when checking plugin discovery and field attachment, while a unit test can exercise the validator quickly. At minimum, test values such as 2000, 0800, 3000, 4000, 999, 20000, strings containing spaces, and non-numeric text.

A simple unit test can instantiate the constraint and validator, then verify whether the execution context receives a violation. In a complete Drupal test, create content through the entity API and call $entity->validate(). This confirms that the constraint is attached to the intended bundle and that the error is connected to the correct field.

Test Cases Worth Covering

Use a translation-aware message for editor-facing errors. The @Translation annotation on the label and the translatable message strategy help when a site serves multiple languages. Avoid exposing internal implementation details such as regular expressions or plugin IDs in the message.

Validation also intersects with privacy and compliance. Do not log complete addresses or customer details merely to diagnose a failed postcode check. For Australian organisations, the Privacy Act 1988 and the Australian Privacy Principles make data minimisation and careful handling of personal information important operational considerations. Log a field name, entity ID, and safe diagnostic code where possible, rather than the entire submitted address.

Operational Checks Before Deployment

A validation rule is part of the site’s data contract. If requirements change, update the tests and deploy the constraint with configuration or code changes in the same release. For a local Australian market campaign, for example, a temporary postcode restriction should be represented as a deliberate business rule with an expiry plan, not hidden inside an unexplained regular expression.

A custom Drupal constraint remains small in code but broad in effect. Keeping the validator focused, attaching it at the right model layer, and testing real entity-saving paths creates dependable field validation without duplicating logic across forms. That makes future administration, migrations, and integrations safer as the Drupal site grows.