Creating a Custom Field Type in Drupal with Validation and PHP Attributes

When building Drupal sites, standard field types like text, number, or entity reference cover most use cases. However, you will occasionally run into requirements where data must follow a strict, predictable business format.

calendar_today
folder_openForms

Instead of slapping random validation functions onto a standard text field across different content types, the clean, reusable approach is to create a Custom Field Type.

In this tutorial, we will create a custom field called Mint Enquiry Reference. This field will capture data that must strictly start with E- followed by exactly six digits (e.g., E-123123), bundle its own form validation, and control how it displays on the frontend.

The Anatomy of a Drupal Field

To create a fully functional custom field type in Drupal, you need three distinct components working together:

  1. The Field Type (FieldItemBase): Defines how the data is stored in the database schema.
  2. The Field Widget (WidgetBase): Defines the form element used when creating or editing content.
  3. The Field Formatter (FormatterBase): Defines how the field data is rendered for the end user.

Step 1: Define the Database Schema (The Field Type)

First, we create the plugin that tells Drupal what our field is and how to store it. Thanks to modern Drupal practices, we can use the #[FieldType] PHP attribute to handle plugin discovery natively. Create a file:

warning
Native PHP attributes for FieldType, FieldWidget, and FieldFormatter plugins were introduced in Drupal 10.3. If you are developing for a site running an older version (such as Drupal 10.2 or early 9.x releases), these attributes will not be recognized. In those environments, you must use traditional Doctrine annotations instead. For full details on the conversion, review the official Drupal Core Change Record.
descriptionsrc/Plugin/Field/FieldType/MintItem.php
1<?php
2
3namespace Drupal\mint_field\Plugin\Field\FieldType;
4
5use Drupal\Core\Field\Attribute\FieldType;
6use Drupal\Core\Field\FieldItemBase;
7use Drupal\Core\Field\FieldStorageDefinitionInterface;
8use Drupal\Core\StringTranslation\TranslatableMarkup;
9use Drupal\Core\TypedData\DataDefinition;
10
11/**
12 * Provides an example field type for storing enquiry references.
13 */
14#[FieldType(
15  id: "field_mint",
16  label: new TranslatableMarkup("Mint Enquiry Reference"),
17  description: new TranslatableMarkup("Stores unique customer enquiry references."),
18  default_widget: "mint_widget",
19  default_formatter: "mint_formatter",
20)]
21class MintItem extends FieldItemBase {
22
23  /**
24   * {@inheritdoc}
25   */
26  public static function schema(FieldStorageDefinitionInterface $field_definition) {
27    return [
28      'columns' => [
29        'value' => [
30          'type' => 'text',
31          'size' => 'tiny',
32          'not null' => FALSE,
33        ],
34      ],
35    ];
36  }
37
38  /**
39   * {@inheritdoc}
40   */
41  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
42    $properties = [];
43    $properties['value'] = DataDefinition::create('string')
44      ->setLabel(t('Value'))
45      ->setRequired(TRUE);
46
47    return $properties;
48  }
49
50  /**
51   * {@inheritdoc}
52   */
53  public function isEmpty() {
54    $value = $this->get('value')->getValue();
55    return $value === NULL || $value === '';
56  }
57
58}
warning
The 'mint_field' is an empty module I created, you will replace it with the machine name of the module you are working on. The class MintItem is also an example name and in real word scenario, you will replace it with something more meaningful for the purpose of the field type.

Key Takeaways from the Schema:

  • The Attribute Mapping: The #[FieldType] block explicitly points to the default_widget and default_formatter we are about to create. This streamlines the UI configuration out of the box.
  • schema(): We are keeping storage light by defining our storage value type as a tiny text block.

Step 2: Enforce the "E-XXXXXX" Pattern (The Field Widget)

Next, we need a form element where users can type in the value. Rather than writing external hooks, we can handle the strict "starts with E- and has 6 digits" business requirement using an #element_validate callback nested directly within our widget class. Create another file:

descriptionsrc/Plugin/Field/FieldWidget/MintWidget.php
1<?php
2
3namespace Drupal\mint_field\Plugin\Field\FieldWidget;
4
5use Drupal\Core\Field\Attribute\FieldWidget;
6use Drupal\Core\Field\FieldItemListInterface;
7use Drupal\Core\Field\WidgetBase;
8use Drupal\Core\Form\FormStateInterface;
9use Drupal\Core\StringTranslation\TranslatableMarkup;
10
11/**
12 * A widget bar.
13 */
14#[FieldWidget(
15  id: 'mint_widget',
16  label: new TranslatableMarkup('Default Enquiry Field'),
17  field_types: [
18    'field_mint',
19  ],
20)]
21class MintWidget extends WidgetBase {
22    /**
23     * {@inheritdoc}
24     */
25    public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
26        $value = isset($items[$delta]->value) ? $items[$delta]->value : '';
27        $element += [
28            '#type' => 'textfield',
29            '#default_value' => $value,
30            '#size' => 8,
31            '#maxlength' => 8,
32            '#element_validate' => [
33                [static::class, 'validate'],
34            ],
35        ];
36        return ['value' => $element];
37    }
38
39    /**
40     * Validate the field.
41     */
42    public static function validate(array $element, FormStateInterface $form_state) {
43        $value = $element['#value'];
44        if (strlen($value) == 0) {
45            $form_state->setValueForElement($element, '');
46            return;
47        }
48
49        // Allow empty values if the field is not required.
50        if ($value === '') {
51            $form_state->setValueForElement($element, '');
52            return;
53        }
54
55        // Must start with "E-".
56        if (!str_starts_with($value, 'E-')) {
57            $form_state->setError($element, t('The enquiry reference must start with "E-".'));
58            return;
59        }
60
61        $number = substr($value, 2);
62
63        // Must contain exactly six digits after the prefix.
64        if (!ctype_digit($number) || strlen($number) !== 6) {
65            $form_state->setError($element, t('The enquiry reference must contain exactly six digits after "E-".'));
66            return;
67        }
68
69        // Store the trimmed value.
70        $form_state->setValueForElement($element, $value);
71    }
72}

Step 3: Render the Enquiry Reference (The Field Formatter)

Lastly, we need to instruct Drupal how to render this data when a node or entity is viewed on the frontend. We will encapsulate the output values inside a semantic <div> tag but you are free to change the HTML structure however you wish.
Create another file:

descriptionsrc/Plugin/Field/FieldFormatter/MintFormatter.php
1<?php
2
3namespace Drupal\mint_field\Plugin\Field\FieldFormatter;
4
5use Drupal\Core\Field\Attribute\FieldFormatter;
6use Drupal\Core\Field\FormatterBase;
7use Drupal\Core\Field\FieldItemListInterface;
8use Drupal\Core\StringTranslation\TranslatableMarkup;
9
10/**
11 * Provides a default formatter for the Mint field type.
12 */
13#[FieldFormatter(
14  id: 'mint_formatter',
15  label: new TranslatableMarkup('Enquiry Default Format'),
16  field_types: [
17    'field_mint',
18  ]
19)]
20class MintFormatter extends FormatterBase {
21
22  /**
23   * {@inheritdoc}
24   */
25  public function viewElements(FieldItemListInterface $items, $langcode) {
26    $element = [];
27
28    foreach ($items as $delta => $item) {
29      // Wrap the database value safely inside an HTML wrapper tag for theme consistency.
30      $element[$delta] = [
31        '#type' => 'html_tag',
32        '#tag' => 'div',
33        '#value' => $item->value,
34      ];
35    }
36
37    return $element;
38  }
39
40}

Seeing It All in Action

Once your files are saved into their respective directories, clear the Drupal cache (drush cr) or activate the module if it is not already.

Head over to any Content Type management screen (e.g., Structure > Content types > Article > Manage fields), click Add field, and you will see Mint Enquiry Reference listed as a native option. Try adding it, then create a test node to watch your strict format validation catch formatting errors in real time!

Lets see the screenshots:

As you can see, the field type is available for use in content types (or for any entity type), lets click on it and provide the field name and finish adding the field.

Follow the usual process for creating the field. After the field is added to your content type, you will notice that in manage form display section, the default widget that we created is selected for the field:

Lets try providing a string not matching the above pattern of E-XXXXXX when creating content of type using this field. We will get a validation error as we expect:

 

Tags

Forms APIFormsField TypeFormatWidget