Drupal Field Widgets Explained: Building a Composite RGB Inputfor Drupal 11 , and 10

Last updated :  

In the last lesson you saw that the RgbItem field type stores exactly one thing: a single hex color string, like #ff5733, in one database column. But nobody wants to type a raw hex code into a plain text box and hope they got it right. This lesson is about the plugin that stands between the raw stored value and the person editing content — the widget — and it does something genuinely interesting: it splits that one stored string into three separate, friendlier input boxes.

What you'll learn in this lesson

  • What a Field Widget plugin actually does, and how it's different from the Field Type
  • How the @FieldWidget annotation limits a widget to only the field types it's compatible with
  • How to parse an existing stored value back into a form's default values — a step beginners often forget, which silently loses data on edit
  • How Form API's #element_validate lets a widget reassemble several inputs back into one stored value
Quick recap: a field type defines what's stored. A widget defines how it's edited. A formatter (next lesson) defines how it's displayed. Same field, three different jobs, three different plugin types.

The source file

Path: modules/field_example/src/Plugin/Field/FieldWidget/Text3Widget.php

<?php

namespace Drupal\field_example\Plugin\Field\FieldWidget;

use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Plugin implementation of the 'field_example_3text' widget.
 *
 * @FieldWidget(
 *   id = "field_example_3text",
 *   module = "field_example",
 *   label = @Translation("RGB text field"),
 *   field_types = {
 *     "field_example_rgb"
 *   }
 * )
 */
class Text3Widget extends WidgetBase {

  /**
   * {@inheritdoc}
   */
  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
    $value = $items[$delta]->value ?? '';
    // Parse the single hex string into RBG values.
    if (!empty($value)) {
      preg_match_all('@..@', substr($value, 1), $match);
    }
    else {
      $match = [[]];
    }

    // Set up the form element for this widget.
    $element += [
      '#type' => 'details',
      '#element_validate' => [
        [$this, 'validate'],
      ],
    ];

    // Add in the RGB textfield elements.
    foreach ([
      'r' => $this->t('Red'),
      'g' => $this->t('Green'),
      'b' => $this->t('Blue'),
    ] as $key => $title) {
      $element[$key] = [
        '#type' => 'textfield',
        '#title' => $title,
        '#size' => 2,
        '#default_value' => array_shift($match[0]),
        '#attributes' => ['class' => ['rgb-entry']],
        '#description' => $this->t('The 2-digit hexadecimal representation of @color saturation, like "a1" or "ff"', ['@color' => $title]),
      ];
      // Since Form API doesn't allow a fieldset to be required, we
      // have to require each field element individually.
      if ($element['#required']) {
        $element[$key]['#required'] = TRUE;
      }
    }
    return ['value' => $element];
  }

  /**
   * Validate the fields and convert them into a single value as text.
   */
  public function validate($element, FormStateInterface $form_state) {
    // Validate each of the textfield entries.
    $values = [];
    foreach (['r', 'g', 'b'] as $colorfield) {
      $values[$colorfield] = $element[$colorfield]['#value'];
      // If they left any empty, we'll set the value empty and quit.
      if (strlen($values[$colorfield]) == 0) {
        $form_state->setValueForElement($element, '');
        return;
      }
      // If they gave us anything that's not hex, reject it.
      if ((strlen($values[$colorfield]) != 2) || !ctype_xdigit($values[$colorfield])) {
        $form_state->setError($element[$colorfield], $form_state, $this->t("Saturation value must be a 2-digit hexadecimal value between 00 and ff."));
      }
    }

    // Set the value of the entire form element.
    $value = strtolower(sprintf('#%02s%02s%02s', $values['r'], $values['g'], $values['b']));
    $form_state->setValueForElement($element, $value);
  }

}

How it works

The @FieldWidget annotation

/**
 * @FieldWidget(
 *   id = "field_example_3text",
 *   module = "field_example",
 *   label = @Translation("RGB text field"),
 *   field_types = {
 *     "field_example_rgb"
 *   }
 * )
 */

Same discovery mechanism as the field type from the last lesson: Drupal scans for classes bearing @FieldWidget, no separate registration hook needed. The one key worth focusing on is field_types — a list of Field Type plugin IDs this widget is allowed to work with. Because it lists only field_example_rgb, this widget will only ever show up as an option when configuring the form display for that specific field type. Try adding a text field instead, and "RGB text field" simply won't be in the list of available widgets — Drupal filters it out for you automatically.

Extending WidgetBase

Just like field types extend FieldItemBase, widgets extend WidgetBase, which handles the repetitive scaffolding — reading widget settings, looping over multiple values for multi-value fields, providing the $this->t() translation helper — leaving Text3Widget to implement just one required method: formElement().

Reading the existing value and parsing it apart

$value = $items[$delta]->value ?? '';
if (!empty($value)) {
  preg_match_all('@..@', substr($value, 1), $match);
}
else {
  $match = [[]];
}

This is the part that's easy to skip when writing your first widget, and the consequences are painful: if you don't parse the existing value back into your form elements, every time an editor reopens a saved node, they'll see blank inputs — as if their previous data vanished. Here, $items[$delta]->value reads the stored string (something like #a1ff3c), substr($value, 1) strips the leading #, and the regex @..@ (using @ as the delimiter character, not part of the pattern) grabs every consecutive pair of characters — turning a1ff3c into ['a1', 'ff', '3c']. When there's no existing value yet (a brand new node), $match is seeded as [[]] so the next step doesn't throw a PHP warning trying to shift an item off a non-existent array.

Wrapping everything in a collapsible details element

$element += [
  '#type' => 'details',
  '#element_validate' => [
    [$this, 'validate'],
  ],
];

Two things happen here. First, += (not =) merges new keys onto $element without overwriting whatever the field system already put there — things like #title and #required, set before your widget ever runs. Overwrite those and you'd silently lose the field's actual label and required state. Second, #element_validate registers validate() (defined further down the same class) as a callback that Form API will run automatically during form validation — this is the mechanism that eventually stitches the three separate inputs back into one value.

Building the three Red/Green/Blue inputs

foreach ([
  'r' => $this->t('Red'),
  'g' => $this->t('Green'),
  'b' => $this->t('Blue'),
] as $key => $title) {
  $element[$key] = [
    '#type' => 'textfield',
    '#title' => $title,
    '#size' => 2,
    '#default_value' => array_shift($match[0]),
    '#attributes' => ['class' => ['rgb-entry']],
    '#description' => $this->t('The 2-digit hexadecimal representation of @color saturation, like "a1" or "ff"', ['@color' => $title]),
  ];
  if ($element['#required']) {
    $element[$key]['#required'] = TRUE;
  }
}

Each pass through the loop adds one child render element — keyed r, then g, then b — onto the parent. array_shift($match[0]) pops the next parsed component off the front of the array each time, so the three fields get pre-filled with the right value in the right order. The last bit — manually copying #required onto each child — exists because of a genuine HTML limitation: a <fieldset> or <details> container has no required attribute of its own, only individual <input> elements do, so the widget has to propagate that requirement down by hand.

The return value: ['value' => $element]

This one detail is what connects this widget back to the field type from the last lesson. Remember that RgbItem::schema() declared exactly one database column, named value. The widget's return value must be keyed to match — ['value' => $element] — so Drupal knows which stored column this whole render array is ultimately feeding into. Get the key wrong and Drupal has no idea where your submitted data is supposed to go.

validate() — turning three inputs back into one value

public function validate($element, FormStateInterface $form_state) {
  $values = [];
  foreach (['r', 'g', 'b'] as $colorfield) {
    $values[$colorfield] = $element[$colorfield]['#value'];
    if (strlen($values[$colorfield]) == 0) {
      $form_state->setValueForElement($element, '');
      return;
    }
    if ((strlen($values[$colorfield]) != 2) || !ctype_xdigit($values[$colorfield])) {
      $form_state->setError($element[$colorfield], $form_state, $this->t("Saturation value must be a 2-digit hexadecimal value between 00 and ff."));
    }
  }

  $value = strtolower(sprintf('#%02s%02s%02s', $values['r'], $values['g'], $values['b']));
  $form_state->setValueForElement($element, $value);
}

This callback runs automatically once Form API has collected what was submitted, thanks to the #element_validate hookup from earlier. It does two jobs at once: validation and value assembly.

  • Empty handling: if any one of the three boxes was left blank, the whole composite value is set to an empty string and the method returns early — that's "no value entered," not an error.
  • Format validation: strlen() != 2 and !ctype_xdigit() together enforce "exactly two valid hex characters." Fail this and $form_state->setError() attaches the error to that specific sub-field, so Drupal highlights the exact box that's wrong — not the whole widget.
  • Reassembly: sprintf('#%02s%02s%02s', ...) glues the three two-character strings back into one hex color string, and $form_state->setValueForElement($element, $value) replaces the submitted array of three sub-values with that single assembled string — the exact shape the field type's value column expects.

Quick check: if this widget's validate() method didn't exist at all, what would actually get saved to the database? (Answer: an array of three separate strings, not the single hex string the field type's schema expects — which would either error out or store garbage, since the field type only has one value column.)

See it for yourself

Visit any article's edit form on your DDEV site and open the "Favorite Color" field group.

The Favorite Color field on an article edit form, showing three separate Red, Green, and Blue hex input boxes

Three separate boxes — Red, Green, Blue — each two characters wide, each pre-filled from the stored value. That's formElement()'s parsing logic in action: the single stored hex string #ff5733 came back apart as FF, 57, and 33. Change any of the three values and save, and validate() will reassemble them back into one hex string before it ever reaches the database.

Key takeaways

  • A Field Widget is declared with @FieldWidget, which specifies a machine ID, a human label, and — critically — a field_types allowlist controlling which field types it's even offered as an option for.
  • Every widget extends WidgetBase and implements formElement(), which must return a render array keyed to match the field type's schema column name (here, ['value' => $element]).
  • Always parse the existing stored value back into your form's default values inside formElement() — skip this and editors will see blank fields (and think their data was lost) every time they reopen a saved entity.
  • Use +=, not =, when adding keys to the incoming $element array, so you don't clobber metadata the field system already set (like #title and #required).
  • A composite widget — one splitting a single value across multiple inputs — needs an #element_validate callback to reassemble those inputs back into the one value the field type actually stores.
  • <fieldset>/<details> containers have no native HTML required attribute, so a widget that groups multiple inputs has to propagate #required onto each child input manually.

Coming up next

You've now seen a value go in via three input boxes and come back out as one stored string. Next: the formatter, which takes that same stored value and decides how it's actually displayed to a site visitor who never sees the edit form at all.