Block, Field Type, Widget and Formatter Plugins

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

A Drupal site is assembled from plugins: the blocks placed in regions, the field types that decide what a field stores, the widgets that edit it and the formatters that display it are all plugins, found by a plugin manager and created on demand. Almost every real module ends up providing at least one of these four kinds. This page writes one of each for the Meridian Museum site: a "Featured object" block with its own settings form and honest cache metadata, a formatter that presents accession numbers, a new meridian_dimensions field type with a database schema, and the widget and a second formatter that go with it. The general machinery (discovery, definitions, derivers, contexts, writing your own plugin type) is explained in the Plugin API and attributes; here the focus is on the four plugin types that a module author meets first.

This page was checked against the official documentation, change records and the Drupal 11.x API reference on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see Requirements and hosting). The examples extend the custom module meridian_collection of the fictitious Meridian Museum (see Module anatomy and info.yml and Custom entity types for the collection_object entity they work on). Drupal 10 reaches its end of life on 2026-12-09 and Drupal 12.0.0 is planned for the week of 2026-12-07, so new code targets Drupal 11.

Who writes these plugins, and why

  • A site builder never writes them, but chooses between them every day: placing a block in the Block layout, choosing a field type when adding a field, picking a widget on Manage form display and a formatter on Manage display (see Content types and fields and Display modes and image styles).

  • A module developer writes a plugin when configuration cannot express the requirement: a block whose content is computed, a field whose value is more than a string or number, an input control that fits the data (three numbers and a unit instead of three unrelated fields), a presentation that no core formatter offers.

  • A themer benefits from formatters and blocks that return clean render arrays with theme hooks or components, because the markup can then be overridden (see Template suggestions and preprocess).

  • An administrator is affected when plugins change IDs or disappear: a missing plugin leaves a placed block or a configured field display broken (see the pitfalls below).

All four plugin types follow the same recipe: a class in a fixed namespace below src/Plugin, a PHP attribute that carries the plugin ID and metadata, a base class that supplies most behaviour and a handful of methods to override. Only the namespace, the attribute class and the methods differ.

Where the four plugins live

Plugin type Namespace (below Drupal\meridian_collection) Attribute class Base class

Block

Plugin\Block

Drupal\Core\Block\Attribute\Block

Drupal\Core\Block\BlockBase

Field type

Plugin\Field\FieldType

Drupal\Core\Field\Attribute\FieldType

Drupal\Core\Field\FieldItemBase

Field widget

Plugin\Field\FieldWidget

Drupal\Core\Field\Attribute\FieldWidget

Drupal\Core\Field\WidgetBase

Field formatter

Plugin\Field\FieldFormatter

Drupal\Core\Field\Attribute\FieldFormatter

Drupal\Core\Field\FormatterBase

The module therefore contains this part of the tree. No .module file and no services.yml entry is needed for any of it: the plugin managers scan the Plugin namespaces of every enabled module.

meridian_collection/
  meridian_collection.info.yml
  meridian_collection.field_type_categories.yml
  config/schema/meridian_collection.schema.yml
  src/
    Plugin/
      Block/FeaturedObjectBlock.php
      Field/
        FieldType/DimensionsItem.php
        FieldWidget/DimensionsWidget.php
        FieldFormatter/AccessionNumberFormatter.php
        FieldFormatter/DimensionsFormatter.php
  tests/src/Kernel/DimensionsFieldTest.php

The following diagram shows how the field plugins cooperate when an editor saves a collection object and a visitor later views it. The field type is the contract in the middle: the widget produces values for it, the storage layer persists its schema, and the formatter reads the same properties back.

flowchart LR E[Editor edits a collection object] --> W[Widget: form element and massageFormValues] W --> I[Field item: property definitions and constraints] I -->|valid| S[(Field storage: columns from schema)] I -->|violation| W S --> L[Field item list loaded from storage] L --> F[Formatter: viewElements] F --> R[Render array with cache metadata] R --> V[Visitor sees the page]

Attributes instead of annotations

Until Drupal 10.1 a plugin was described by a doc-comment annotation (@Block(...), @FieldType(...)) read with the Doctrine annotation parser. PHP attributes for plugin discovery arrived in Drupal 10.2.0, and the official documentation states that annotations still work for backwards compatibility while new plugins should use attributes, provided the plugin manager supports them (core’s managers do). Core converted most of its own plugins in 10.3 and 11.0. The official page gives no removal version for annotation discovery, only that annotation-based modules "will keep working for now", so treat annotations as deprecated and do not write new ones. Core also forked the parts of the abandoned doctrine/annotations package that it still needs (10.6), which is a sign of the direction, not a promise about timing.

Each plugin type has its own attribute class, imported with a use statement. The attribute arguments are the same keys the annotation had, but they are real PHP: named arguments, with human-readable labels written as new TranslatableMarkup('...') instead of the @Translation("...") annotation tag.

Attribute Constructor arguments (Drupal 11.x)

#[Block]

id (required), admin_label, category, context_definitions, deriver, forms

#[FieldType]

id and label (required), description, category, weight, default_widget, default_formatter, no_ui, list_class, cardinality, constraints, config_dependencies, column_groups, serialized_property_names, deriver, module

#[FieldWidget]

id (required), label, description, field_types, multiple_values, weight, deriver

#[FieldFormatter]

id (required), label, description, field_types, weight, deriver

Older books such as Drupal 10 Development Cookbook (2023) and many tutorials show the annotation form, and the drupal.org page "FieldTypes, FieldWidgets and FieldFormatters" in the Entity API guide still uses @FieldType, @FieldWidget and @FieldFormatter in its worked example, with the widget declared by field_types = {"..."} and the formatter by the same key. The class structure it teaches (a FieldItemBase subclass with propertyDefinitions() and schema(), a WidgetBase subclass with formElement(), a FormatterBase subclass with viewElements()) is still correct; only the declaration changes: replace the doc comment with the attribute from the table above. A mechanical conversion of existing plugins is covered by the conversion guides linked from the Plugin API page and by Drupal Rector.

Plugins also used to read their services through \Drupal::service() in the body of a method. That works, but it hides dependencies and makes tests harder. Constructor injection is the rule for new code (see Services and dependency injection); \Drupal:: static calls belong in procedural code and tests only. Block plugins implement ContainerFactoryPluginInterface and receive their services from a static create() method, as the first example shows. PluginBase also supports autowiring through AutowiredInstanceTrait, and FormatterBase::create() already uses it. Since Drupal 11.4.0, classes that use the autowire traits can also receive dependencies through setter methods marked with Symfony’s #[Required] attribute, so subclasses need not override the constructor. An explicit create() works on every supported release and is used in the examples.

The Meridian homepage needs a block that shows one collection object chosen by a curator, in a view mode that the curator also chooses. The block is configurable (so it needs a settings form), it shows another entity (so it must respect that entity’s access) and its output changes when that entity changes (so it needs cache tags). Content blocks and the Block layout screen are covered in Blocks and Block layout, which also contains a first plugin, "On display now". This one shows the parts that page does not: an entity reference in the settings form, validation, dependency injection of several services and per-entity cache metadata.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\Block;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Shows one collection object chosen by a curator.
 */
#[Block(
  id: 'meridian_featured_object',
  admin_label: new TranslatableMarkup('Featured object'),
  category: new TranslatableMarkup('Meridian'),
)]
final class FeaturedObjectBlock extends BlockBase implements ContainerFactoryPluginInterface {

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly EntityDisplayRepositoryInterface $displayRepository,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('entity_type.manager'),
      $container->get('entity_display.repository'),
    );
  }

  public function defaultConfiguration(): array {
    return [
      'object' => NULL,
      'view_mode' => 'teaser',
    ] + parent::defaultConfiguration();
  }

  public function blockForm($form, FormStateInterface $form_state): array {
    $object = $this->configuration['object']
      ? $this->entityTypeManager->getStorage('collection_object')->load($this->configuration['object'])
      : NULL;

    $form['object'] = [
      '#type' => 'entity_autocomplete',
      '#title' => $this->t('Collection object'),
      '#target_type' => 'collection_object',
      '#default_value' => $object,
      '#required' => TRUE,
    ];
    $form['view_mode'] = [
      '#type' => 'select',
      '#title' => $this->t('View mode'),
      '#options' => $this->displayRepository->getViewModeOptions('collection_object'),
      '#default_value' => $this->configuration['view_mode'],
    ];
    return $form;
  }

  public function blockValidate($form, FormStateInterface $form_state): void {
    $modes = $this->displayRepository->getViewModeOptions('collection_object');
    if (!isset($modes[$form_state->getValue('view_mode')])) {
      $form_state->setErrorByName('view_mode', $this->t('Choose one of the available view modes.'));
    }
  }

  public function blockSubmit($form, FormStateInterface $form_state): void {
    $this->configuration['object'] = $form_state->getValue('object');
    $this->configuration['view_mode'] = $form_state->getValue('view_mode');
  }

  protected function blockAccess(AccountInterface $account): AccessResultInterface {
    return AccessResult::allowedIfHasPermission($account, 'access content');
  }

  public function build(): array {
    if (!$this->configuration['object']) {
      return [];
    }
    $object = $this->entityTypeManager
      ->getStorage('collection_object')
      ->load($this->configuration['object']);
    if ($object === NULL) {
      return [];
    }

    // The block shows another entity, so it must honour that entity's access.
    $access = $object->access('view', NULL, TRUE);
    $build = [];
    if ($access->isAllowed()) {
      $build = $this->entityTypeManager
        ->getViewBuilder('collection_object')
        ->view($object, $this->configuration['view_mode']);
    }
    // Bubble the access result's cacheability even when access is denied.
    CacheableMetadata::createFromObject($access)->applyTo($build);
    return $build;
  }

  public function getCacheTags(): array {
    $tags = parent::getCacheTags();
    if ($this->configuration['object']) {
      $tags = Cache::mergeTags($tags, ['collection_object:' . $this->configuration['object']]);
    }
    return $tags;
  }

}

How the block works

The attribute makes the class discoverable. id is the plugin ID (it ends up in the placed block’s configuration entity, so it must never change), admin_label is the name shown in the "Place block" dialog and category groups the block in that dialog. Required context is declared with context_definitions, for example a node context for a block that only makes sense on a node page; a block set up this way is not offered where the context is missing. The forms argument lets a block name additional form classes, keyed by purpose; Settings Tray used it, and that module leaves core for contrib in Drupal 12.

The administrative flow is: Structure > Block layout > Place block > the plugin’s Configure button. The configure form is built by BlockBase::buildConfigurationForm(), which adds the block title, the "Display title" checkbox and the visibility tabs, and then calls the plugin’s blockForm() with a sub-form state. The consequence is that blockSubmit() and blockValidate() read values with $form_state->getValue('object') directly, without the settings prefix that the full form uses. The values stored on the placed block are whatever $this->configuration holds after blockSubmit(), under settings in the block.block.* configuration entity, next to the plugin ID. Those keys must be described in configuration schema, covered below.

The methods in the example, in the order Drupal calls them:

  • defaultConfiguration() supplies the initial settings. Always merge parent::defaultConfiguration(), which holds the cache settings and the label-display flag.

  • blockForm(), blockValidate() and blockSubmit() build, validate and store the settings. An entity_autocomplete element returns the entity ID when the target type is set; the #default_value must be the loaded entity, not the ID.

  • blockAccess() is the plugin’s own access check, in addition to the block’s visibility conditions. It returns an AccessResult that carries cache metadata; allowedIfHasPermission() adds the user.permissions cache context for you.

  • build() returns a render array. Returning an empty array hides the block, and Drupal then also hides the block title and wrapper.

  • getCacheTags(), getCacheContexts() and getCacheMaxAge() describe what the output depends on at the block level.

Cacheability

A block is rendered as part of a page and may be cached on its own, in the Dynamic Page Cache and the Internal Page Cache, in a reverse proxy or CDN. The cache metadata must therefore say everything the output depends on, or visitors will see stale or someone else’s content. Three kinds of metadata exist:

  • Cache tags say which stored data the output was built from. The block above shows a collection object, so it carries collection_object:ID; when a curator saves that object, Drupal invalidates the tag and every cached copy of the block disappears. The view builder adds the same tag to the object’s render array, but listing it in getCacheTags() too also covers the case where build() returns an empty array because access was denied or the object was unpublished: the block then reappears when the object changes.

  • Cache contexts say what the output varies by: user.permissions, languages:language_interface, url.path, route. The access result of $object->access('view', NULL, TRUE) carries the contexts the entity’s access depended on, and the example bubbles them into the build array with CacheableMetadata.

  • Max-age says for how many seconds the output may be reused. The default, Cache::PERMANENT, is right for anything invalidated by tags. A block that shows "open now" must return a small max-age or depend on a time-based context. A max-age of 0 makes the whole page uncacheable for every user, so reserve it for output that really is dynamic and prefer lazy builders for small dynamic pieces.

The common error is a block that varies by something without declaring it. A block that prints the current user’s name without the user context is cached once and shown to everybody. When in doubt, add the context; an unnecessary context costs cache efficiency, a missing one costs correctness. The same rules apply to every render array, and the Render API page explains the bubbling in depth (see Render API and cache metadata and Performance and caching).

The bubbling flow of the featured-object block is summarised here.

flowchart TD A[Curator saves collection object 42] --> B[Tag collection_object:42 invalidated] B --> C[Cached block fragment dropped] B --> D[Dynamic Page Cache entries tagged with it dropped] B --> E[Reverse proxy purged by tag, if configured] C --> F[Next request: build runs again] F --> G[Access check and view builder add tags and contexts] G --> H[Fresh fragment cached with the same tags]

Configuration schema for the block

Placed blocks are configuration entities, so the settings of a custom block plugin need a schema. Without one, saving the block works in the browser but fails in kernel and functional tests with a schema exception, and translation and validation of the settings are impossible. The schema key for block settings is block.settings. followed by the plugin ID, and it extends block_settings.

# config/schema/meridian_collection.schema.yml
block.settings.meridian_featured_object:
  type: block_settings
  label: 'Featured object block'
  mapping:
    object:
      type: integer
      label: 'Collection object ID'
      nullable: true
    view_mode:
      type: string
      label: 'View mode'

The Configuration API and schema are described in Configuration API and schema. Because the block entity is exported with drush config:export, the featured object’s numeric ID would travel between environments; if the IDs differ between staging and production, reference the object by a stable key such as its accession number instead of its serial ID.

A field formatter for accession numbers

An accession number identifies one object in a museum, for example MM-1987-0042 (museum, year of acquisition, serial). The accession_number base field of collection_object is a plain string field. Curators want the number shown in a fixed style, optionally linking to the object, and the Meridian site wants the parts consistent whatever the editor typed (mm-1987-42 becomes MM-1987-0042). A formatter is the right place for that: it changes the presentation of the stored value without changing the data.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\Field\FieldFormatter;

use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Formats an accession number in its canonical style, optionally as a link.
 */
#[FieldFormatter(
  id: 'meridian_accession_number',
  label: new TranslatableMarkup('Accession number'),
  field_types: ['string'],
)]
final class AccessionNumberFormatter extends FormatterBase {

  public static function defaultSettings(): array {
    return [
      'link_to_object' => TRUE,
    ] + parent::defaultSettings();
  }

  public static function isApplicable(FieldDefinitionInterface $field_definition): bool {
    // Offer the formatter only on the collection object entity type.
    return $field_definition->getTargetEntityTypeId() === 'collection_object';
  }

  public function settingsForm(array $form, FormStateInterface $form_state): array {
    $form = parent::settingsForm($form, $form_state);
    $form['link_to_object'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Link to the object page'),
      '#default_value' => $this->getSetting('link_to_object'),
    ];
    return $form;
  }

  public function settingsSummary(): array {
    return [
      $this->getSetting('link_to_object')
        ? $this->t('Linked to the object page')
        : $this->t('Not linked'),
    ];
  }

  public function viewElements(FieldItemListInterface $items, $langcode): array {
    $elements = [];
    $entity = $items->getEntity();

    foreach ($items as $delta => $item) {
      $text = self::normalise((string) $item->value);
      if ($text === '') {
        continue;
      }
      $elements[$delta] = [
        '#type' => 'html_tag',
        '#tag' => 'span',
        '#attributes' => ['class' => ['accession-number']],
      ];
      if ($this->getSetting('link_to_object') && !$entity->isNew()) {
        $elements[$delta]['link'] = [
          '#type' => 'link',
          '#title' => $text,
          '#url' => $entity->toUrl(),
        ];
      }
      else {
        // #plain_text escapes the value: it came from an editor.
        $elements[$delta]['#plain_text'] = $text;
      }
    }
    return $elements;
  }

  /**
   * Turns "mm-1987-42" into "MM-1987-0042"; other input is only upper-cased.
   */
  private static function normalise(string $value): string {
    $value = strtoupper(trim($value));
    if (preg_match('/^([A-Z]{2,4})-(\d{4})-(\d{1,5})$/', $value, $m)) {
      return sprintf('%s-%s-%04d', $m[1], $m[2], (int) $m[3]);
    }
    return $value;
  }

}

How a formatter works

A formatter is selected per view mode and field on Manage display (or in code, with setComponent() on an EntityViewDisplay). The entity view builder loads the entity, checks field-level view access for each field, calls viewElements() once for the whole field item list, and wraps the result in the field theme (label, wrapper and one field__item per delta). That has these consequences:

  • viewElements() returns an array indexed by delta, one render array per item. Empty values should be skipped, as above, so that the field wrapper is not printed for nothing.

  • field_types limits where the formatter is offered; here string. For formatters that apply to a single field only, override isApplicable() as in the example. A formatter that the Field UI never offers cannot be selected in the UI at all, which is the usual reason for "my formatter does not show up": the attribute listed a different field type, the module was not enabled, or the cache had not been rebuilt after adding the class.

  • defaultSettings(), settingsForm() and settingsSummary() define the configurable options shown behind the gear icon on Manage display. The values are stored in the display configuration entity (core.entity_view_display.*) under settings, so they need schema, shown at the end of this section.

  • If the output depends on the user, the language or a query argument, add the matching cache context to the elements; a formatter result is cached with the entity render array.

  • prepareView() is called with all items of all entities being displayed before viewElements(). Use it to load referenced data in bulk and avoid a query per entity.

The formatter does not decide who may open the page it links to: the target route does its own access check. A formatter that prints data from another entity must check that entity’s access itself, as the block above does.

Attach the formatter to the field through the entity’s base field definition, or by configuration for fields created in the UI. The relevant part of the entity class is:

// In Drupal\meridian_collection\Entity\CollectionObject::baseFieldDefinitions().
$fields['accession_number'] = BaseFieldDefinition::create('string')
  ->setLabel(new TranslatableMarkup('Accession number'))
  ->setRequired(TRUE)
  ->setSetting('max_length', 32)
  ->addConstraint('UniqueField')
  ->setDisplayOptions('view', [
    'label' => 'inline',
    'type' => 'meridian_accession_number',
    'settings' => ['link_to_object' => TRUE],
    'weight' => 0,
  ])
  ->setDisplayConfigurable('view', TRUE);

Formatter settings need schema under field.formatter.settings. followed by the plugin ID, as does every widget under field.widget.settings.. Both are in the combined schema file at the end of the field section.

A field type with a storage schema

A field type defines what one value of a field looks like and how it is stored. Core covers strings, numbers, dates, references and files. The Meridian curators also record the dimensions of an object: height, width, depth and a unit. Three separate decimal fields would have no connection with each other and no shared unit; one field type with four columns keeps a value together, validates it as a unit and gives the widget and the formatter a single thing to work with.

The category file

Since Drupal 10.2 the category argument of #[FieldType] is the ID of a field type category, not a label. Modules declare their own categories in MODULE_NAME.field_type_categories.yml, and core defines the usual ones (such as number) in core.field_type_categories.yml. Older code that passed a translatable string as the category was deprecated by that change and has been reported to raise assertion errors on current releases. Declare a category for the module (or use a core ID) before referring to it.

# meridian_collection.field_type_categories.yml
meridian:
  label: Meridian
  description: Field types for museum collections.
  weight: 10

The field item class

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\Field\FieldType;

use Drupal\Core\Field\Attribute\FieldType;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TypedData\DataDefinition;

/**
 * Height, width and depth of a collection object, with a unit.
 */
#[FieldType(
  id: 'meridian_dimensions',
  label: new TranslatableMarkup('Dimensions'),
  description: new TranslatableMarkup('Height, width and depth of an object, with a unit.'),
  category: 'meridian',
  default_widget: 'meridian_dimensions_default',
  default_formatter: 'meridian_dimensions_default',
)]
final class DimensionsItem extends FieldItemBase {

  public const UNITS = ['mm' => 'mm', 'cm' => 'cm', 'm' => 'm'];

  public static function defaultStorageSettings(): array {
    return ['scale' => 2] + parent::defaultStorageSettings();
  }

  public static function defaultFieldSettings(): array {
    return ['default_unit' => 'cm'] + parent::defaultFieldSettings();
  }

  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition): array {
    $properties = [];
    foreach (['height' => 'Height', 'width' => 'Width', 'depth' => 'Depth'] as $name => $label) {
      // Decimal values travel as strings, like core's decimal field type.
      $properties[$name] = DataDefinition::create('string')
        ->setLabel(new TranslatableMarkup($label))
        ->addConstraint('Range', ['min' => 0]);
    }
    $properties['unit'] = DataDefinition::create('string')
      ->setLabel(new TranslatableMarkup('Unit'))
      ->addConstraint('AllowedValues', array_keys(self::UNITS));
    return $properties;
  }

  public static function schema(FieldStorageDefinitionInterface $field_definition): array {
    $scale = (int) $field_definition->getSetting('scale');
    $decimal = [
      'type' => 'numeric',
      'precision' => 10,
      'scale' => $scale,
      'not null' => FALSE,
    ];
    return [
      'columns' => [
        'height' => $decimal,
        'width' => $decimal,
        'depth' => $decimal,
        'unit' => ['type' => 'varchar', 'length' => 2, 'not null' => FALSE],
      ],
      'indexes' => [
        'height' => ['height'],
      ],
    ];
  }

  public static function mainPropertyName(): string {
    return 'height';
  }

  public function isEmpty(): bool {
    foreach (['height', 'width', 'depth'] as $name) {
      $value = $this->get($name)->getValue();
      if ($value !== NULL && $value !== '') {
        return FALSE;
      }
    }
    return TRUE;
  }

  public function storageSettingsForm(array &$form, FormStateInterface $form_state, $has_data): array {
    $element['scale'] = [
      '#type' => 'select',
      '#title' => $this->t('Decimal places'),
      '#options' => array_combine(range(0, 3), range(0, 3)),
      '#default_value' => $this->getSetting('scale'),
      // The column definition cannot change once values exist.
      '#disabled' => $has_data,
    ];
    return $element;
  }

  public function fieldSettingsForm(array $form, FormStateInterface $form_state): array {
    $element['default_unit'] = [
      '#type' => 'select',
      '#title' => $this->t('Default unit'),
      '#options' => self::UNITS,
      '#default_value' => $this->getSetting('default_unit'),
    ];
    return $element;
  }

  public static function generateSampleValue(FieldDefinitionInterface $field_definition): array {
    return [
      'height' => '12.50',
      'width' => '8.00',
      'depth' => '3.25',
      'unit' => 'cm',
    ];
  }

}

Properties, schema, settings

The class is the contract between three layers.

  • propertyDefinitions() lists the properties of one value as typed data: their data types, labels and constraints. Properties are what PHP code, validation, Views, JSON:API and REST see: $item->height, $item->get('unit')->getValue(). addConstraint() attaches a validation constraint plugin to the property (Range and AllowedValues are Symfony constraints available in core). A constraint violation is reported by the entity validation that runs on form submit, in the REST and JSON:API write paths and in $entity->validate().

  • schema() lists the columns that the SQL storage creates for the field. Its columns become columns of the collection_object__dimensions table (named from the entity type and field, one row per delta per revision per language), prefixed by the field name, for example dimensions_height. indexes and unique keys add database indexes. The official guide notes that schema columns should be a subset of the properties, and that although schema is deprecated on entity types it is still how field types describe their storage. The method is static because Drupal needs the schema before any value exists, when it installs the storage; it receives the storage definition so that storage settings (here scale) can shape the columns.

  • defaultStorageSettings() and defaultFieldSettings() define the settings. Storage settings belong to the field storage and are shared by every bundle that uses the field; once data exists, anything that changes the schema must not be editable, which is what $has_data is for. Field settings belong to the field instance on a bundle and can change at any time. Both are exported as configuration (field.storage.* and field.field.*), so both need schema.

The remaining methods:

  • isEmpty() decides whether the item counts as a value. Items for which it returns TRUE are removed from the list on save, which is why a form submitted with every number blank stores nothing. FieldItemBase inherits a default from Map, but a multi-column type should state its own rule.

  • mainPropertyName() names the property that Views sorting, filtering and token support treat as the item’s value. The default is the first property.

  • generateSampleValue() is used by Devel Generate and by drush devel-generate; it also helps tests.

  • storageSettingsForm() and fieldSettingsForm() build the settings forms on the Field UI pages.

The default_widget and default_formatter arguments name the plugins offered first when a site builder adds the field. They are strings, so the order in which the plugins are written does not matter, but they must match the plugin IDs used by the widget and the formatter below. no_ui: TRUE would hide the type from the "Add field" dialog, useful for types that only base field definitions use. cardinality forces a fixed number of values, and list_class replaces the item list class when the field needs list-level behaviour (for example a computed list).

Using the type

In a base field definition the type is used like any core type, with the widget and formatter chosen by name:

$fields['dimensions'] = BaseFieldDefinition::create('meridian_dimensions')
  ->setLabel(new TranslatableMarkup('Dimensions'))
  ->setSetting('scale', 2)
  ->setDisplayOptions('form', [
    'type' => 'meridian_dimensions_default',
    'weight' => 20,
  ])
  ->setDisplayOptions('view', [
    'label' => 'inline',
    'type' => 'meridian_dimensions_default',
    'weight' => 20,
  ])
  ->setDisplayConfigurable('form', TRUE)
  ->setDisplayConfigurable('view', TRUE);

A field created in the UI is added with Manage fields > Create a new field and the category chosen from the "Meridian" group.

What happens when the schema changes

The storage schema is created when the field storage is created. Adding a column to a field that already holds data is not something the field system can do for you: changing schema() leaves existing sites on the old table, and Drupal refuses to change a storage definition that has data ("field storage is in use"). Treat the column set as final. If a new column becomes necessary, the safe route is a new field type or a new field, a migration of the values with an update hook or a post-update, and a removal of the old field once the data has moved (see Update API and post-updates). Likewise, the plugin ID of a field type is stored in field.storage.* configuration: renaming it orphans every field of that type.

A field widget

The widget is the editing control for a field type on the entity form. The default widget for a four-column type should present the four inputs together, as one labelled group, and send back values in the shape the field item expects.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\Field\FieldWidget;

use Drupal\Core\Field\Attribute\FieldWidget;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\WidgetBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\Plugin\Field\FieldType\DimensionsItem;

/**
 * Edits height, width, depth and unit in one group.
 */
#[FieldWidget(
  id: 'meridian_dimensions_default',
  label: new TranslatableMarkup('Dimensions group'),
  field_types: ['meridian_dimensions'],
)]
final class DimensionsWidget extends WidgetBase {

  public static function defaultSettings(): array {
    return ['show_depth' => TRUE] + parent::defaultSettings();
  }

  public function settingsForm(array $form, FormStateInterface $form_state): array {
    $form = parent::settingsForm($form, $form_state);
    $form['show_depth'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Show the depth input'),
      '#default_value' => $this->getSetting('show_depth'),
    ];
    return $form;
  }

  public function settingsSummary(): array {
    return [$this->getSetting('show_depth') ? $this->t('Height, width and depth') : $this->t('Height and width')];
  }

  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state): array {
    $item = $items[$delta];
    $element += [
      '#type' => 'fieldset',
      '#attributes' => ['class' => ['meridian-dimensions']],
    ];

    $names = ['height' => $this->t('Height'), 'width' => $this->t('Width')];
    if ($this->getSetting('show_depth')) {
      $names['depth'] = $this->t('Depth');
    }
    foreach ($names as $name => $title) {
      $element[$name] = [
        '#type' => 'number',
        '#title' => $title,
        '#default_value' => $item->{$name} ?? NULL,
        '#min' => 0,
        '#step' => 'any',
        '#size' => 8,
      ];
    }
    $element['unit'] = [
      '#type' => 'select',
      '#title' => $this->t('Unit'),
      '#options' => DimensionsItem::UNITS,
      '#default_value' => $item->unit ?? $this->getFieldSetting('default_unit'),
    ];
    return $element;
  }

  public function massageFormValues(array $values, array $form, FormStateInterface $form_state): array {
    foreach ($values as &$value) {
      foreach (['height', 'width', 'depth'] as $name) {
        // An empty number input arrives as '' and must not be stored.
        if (($value[$name] ?? '') === '') {
          $value[$name] = NULL;
        }
      }
    }
    return $values;
  }

}

How a widget works

The entity form display asks each widget for its form element. For a field with cardinality 1 the widget’s formElement() is called once with $delta 0; for a multi-value field WidgetBase builds the "Add another item" table (with drag handles and a remove control) and calls formElement() for every delta. A widget that handles all values itself (for example a tags input) sets multiple_values: TRUE in the attribute and builds the whole list.

On submit, WidgetBase::extractFormValues() collects the submitted values, massageFormValues() converts them into field item arrays, and the entity is validated. Constraint violations are mapped back to widget elements by flagErrors() and errorElement(), which is why the keys of the form element (height, width, depth, unit) should match the property names; a violation on the height property then marks the height input. Widgets implement isApplicable() to hide themselves from fields they do not suit, and settingsForm() and settingsSummary() exactly like formatters. Widget settings are stored in the entity form display configuration entity.

A widget is not a place for business rules. The widget only shapes input; rules (a height must not be negative) belong in constraints on the field type or the entity, because the same item is written through other paths: the REST and JSON:API endpoints, migrations, Drush scripts and the Entity::create() API never run the widget.

A second formatter for the type

The field type named meridian_dimensions_default as its default formatter, so the module needs one.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\Field\FieldFormatter;

use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Shows dimensions as "12.50 × 8.00 × 3.25 cm".
 */
#[FieldFormatter(
  id: 'meridian_dimensions_default',
  label: new TranslatableMarkup('Height × width × depth'),
  field_types: ['meridian_dimensions'],
)]
final class DimensionsFormatter extends FormatterBase {

  public function viewElements(FieldItemListInterface $items, $langcode): array {
    $elements = [];
    foreach ($items as $delta => $item) {
      $parts = array_filter(
        [$item->height, $item->width, $item->depth],
        static fn ($part) => $part !== NULL && $part !== '',
      );
      if ($parts === []) {
        continue;
      }
      $elements[$delta] = [
        '#plain_text' => implode(' × ', $parts) . ' ' . $item->unit,
      ];
    }
    return $elements;
  }

}

#plain_text escapes the output; never use #markup with values that came from an editor unless they pass through a text format or Xss::filter(). The multiplication sign in the example is ordinary text; for a number format that varies with the visitor’s language, format the numbers in the formatter and add the languages:language_interface context.

Configuration schema for the field plugins

Every plugin setting that is saved in configuration needs schema. Field plugins use four fixed prefixes, each followed by the plugin ID (or the field type ID): field.storage_settings.<type>, field.field_settings.<type>, field.widget.settings.<widget> and field.formatter.settings.<formatter>. A fifth, field.value.<type>, describes a default value for the field. The schema for this module is:

# config/schema/meridian_collection.schema.yml
field.storage_settings.meridian_dimensions:
  type: mapping
  label: 'Dimensions storage settings'
  mapping:
    scale:
      type: integer
      label: 'Decimal places'

field.field_settings.meridian_dimensions:
  type: mapping
  label: 'Dimensions field settings'
  mapping:
    default_unit:
      type: string
      label: 'Default unit'

field.value.meridian_dimensions:
  type: mapping
  label: 'Default value'
  mapping:
    height:
      type: string
      label: 'Height'
    width:
      type: string
      label: 'Width'
    depth:
      type: string
      label: 'Depth'
    unit:
      type: string
      label: 'Unit'

field.widget.settings.meridian_dimensions_default:
  type: mapping
  label: 'Dimensions widget settings'
  mapping:
    show_depth:
      type: boolean
      label: 'Show the depth input'

field.formatter.settings.meridian_accession_number:
  type: mapping
  label: 'Accession number formatter settings'
  mapping:
    link_to_object:
      type: boolean
      label: 'Link to the object page'

The schema file also gives the settings a type for configuration translation and, in tests, strict checking: BrowserTestBase and KernelTestBase throw on configuration that is saved without schema, which is the quickest way to discover a forgotten key. The block.settings.meridian_featured_object entry shown earlier goes in the same file.

Enabling and checking the plugins

Plugin definitions are cached. After adding a class or changing an attribute, rebuild caches so that the plugin managers rediscover it, then confirm that the plugin is known.

ddev drush cache:rebuild
ddev drush php:eval "print_r(array_keys(\Drupal::service('plugin.manager.field.field_type')->getDefinitions()));" | grep meridian
ddev drush php:eval "print_r(array_keys(\Drupal::service('plugin.manager.block')->getDefinitions()));" | grep meridian

The \Drupal:: calls in the one-liners are acceptable because they run in a throw-away script, not in module code. Then, in the browser, add the block under Structure > Block layout, open Structure > Content types (or the entity’s Manage form display) to see the widget, and open Manage display to see the formatter.

Testing the field type

A kernel test installs the module, creates a field on a test entity, writes invalid and valid values and asserts the constraint behaviour. PHPUnit 11 attributes replace the @group annotations of older books (supported from Drupal 11.2; PHPUnit 10 support ended in 11.3).

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Kernel;

use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class DimensionsFieldTest extends KernelTestBase {

  protected static $modules = [
    'system', 'user', 'field', 'entity_test', 'meridian_collection',
  ];

  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('entity_test');
    $this->installEntitySchema('user');

    FieldStorageConfig::create([
      'field_name' => 'field_dimensions',
      'entity_type' => 'entity_test',
      'type' => 'meridian_dimensions',
    ])->save();
    FieldConfig::create([
      'field_name' => 'field_dimensions',
      'entity_type' => 'entity_test',
      'bundle' => 'entity_test',
    ])->save();
  }

  public function testNegativeHeightIsRejected(): void {
    $entity = EntityTest::create([
      'name' => 'Sextant',
      'field_dimensions' => ['height' => '-1', 'width' => '2', 'unit' => 'cm'],
    ]);
    $violations = $entity->validate();
    $this->assertCount(1, $violations);
    $this->assertSame('field_dimensions.0.height', $violations[0]->getPropertyPath());
  }

  public function testEmptyItemIsRemovedOnSave(): void {
    $entity = EntityTest::create([
      'name' => 'Sextant',
      'field_dimensions' => ['height' => NULL, 'width' => NULL, 'depth' => NULL],
    ]);
    $entity->save();
    $this->assertTrue($entity->get('field_dimensions')->isEmpty());
  }

}

Test types, base classes, running tests in DDEV and the move from annotations to attributes are described in Automated testing. Block output is tested with a functional test that places the block through drupalPlaceBlock('meridian_featured_object', ['object' => $id]) and asserts both the content and the cache tags in the response headers.

Security

  • Escape output. Formatter and block output is a render array. #plain_text and #markup with a translatable string are escaped; #markup with a raw value is filtered by an XSS filter that allows a small set of tags, and Markup::create() or |raw in Twig turns filtering off. Editor-supplied values (accession numbers, dimensions, titles) go through #plain_text or a text format, never through Markup::create().

  • Check access in blocks. A block’s blockAccess() says who sees the block, not who may see the entity it shows. Call $entity->access('view', NULL, TRUE) before rendering another entity, as the example does, and bubble the access result’s cacheability. The view builder does not check entity access for you.

  • Query with accessCheck(). Entity queries inside a block or formatter must state accessCheck(TRUE) (or FALSE with a written reason); the call is mandatory in current Drupal versions.

  • Validate on the server. The #min of a widget input is a browser hint. Constraints on the property definitions are the real validation and the only validation REST, JSON:API and migrations see.

  • Permissions. Offer a new block through an existing permission (the example requires access content) or a permission defined in meridian_collection.permissions.yml; see Access control and permissions in code and Security hardening.

  • No secrets in settings. A block that calls a remote service reads its API key from settings.php or the environment, never from the block’s configuration, because block configuration is exported to the repository.

Common errors and pitfalls

  • The plugin does not appear. Wrong namespace or directory (Plugin/Block, Plugin/Field/FieldWidget and so on are fixed), a class name that does not match the file name, an attribute from the wrong namespace, the module not enabled, or a stale plugin cache. Rebuild caches first.

  • "The …​ plugin does not exist" after a rename. Plugin IDs are stored in configuration (placed blocks, entity displays, field storages). Renaming an ID or removing a plugin leaves that configuration pointing at nothing. Field displays fall back to the field type’s default plugin; a placed block shows nothing and logs an error; a field type that disappears breaks the field and usually the whole entity form. Keep IDs stable and deprecate in two steps.

  • Annotation and attribute mixed. Having both on one class, or an annotation on a plugin type whose manager now supports attributes, is harmless but confusing; convert the class completely.

  • Missing configuration schema. The browser accepts the form but tests report a schema error, and settings cannot be translated. Add the schema key for every stored setting.

  • Cache metadata left out. The most frequent runtime defect: a block that varies by user, language, URL or a related entity without saying so. Fix with contexts and tags, not with max-age: 0.

  • Different property names in the widget and in the item. The widget stores depth_mm, the type declares depth; the value is silently dropped or the validation error is shown on the wrong element. Use the same keys.

  • Storing empty strings. A number input submits '' for no value; without massageFormValues() the database gets a string in a numeric column (an error on PostgreSQL) or isEmpty() returns the wrong answer.

  • Services fetched statically. \Drupal::entityTypeManager() inside build() works and is the most common code in old tutorials, but it cannot be replaced in tests; use constructor injection.

  • Changing storage with data. Changing schema() or the storage settings after values exist is refused, or worse, works on a fresh install only. Plan new columns as new fields.

  • Kernel tests that rely on the UI. A kernel test has no routes or theme by default; test block rendering in a functional test.

Version notes and corrections

  • Attribute discovery for plugins: Drupal 10.2.0. Annotations remain supported on the 11.x line; the official documentation gives no removal version, so check the deprecation notices of the release you target before relying on them. Core converted most of its plugins in 10.3 and 11.0.

  • Field type categories as IDs: Drupal 10.2. A translatable string as category is deprecated.

  • OOP hooks (#[Hook], Drupal 11.1) and plugin attributes are independent features; hook classes live in src/Hook and plugins in src/Plugin. A module can use both. Field plugins need no hook. See Hooks and OOP hooks.

  • FormatterBase::create() autowires through AutowiredInstanceTrait; setter injection with #[Required] arrived in 11.4.0.

  • Settings Tray, which used the block forms argument for its off-canvas editing, moves to contrib in Drupal 12 (see Preparing for Drupal 12 and Removed core modules and replacements). Block and field plugin APIs used in this page are not listed among the Drupal 12 removals; re-check the 12.0.0 release notes when it is published.

  • Entity queries need an explicit accessCheck() call, and in Drupal 12 query objects are no longer serialisable.

  • Older books such as Drupal 10 Development Cookbook (2023) write plugin classes with the annotation tags @Block, @FieldType, @FieldWidget and @FieldFormatter and call services with \Drupal:: inside plugin methods. Since 10.2 the attributes shown here replace the annotations, and constructor injection replaces the static calls. The drupal.org Entity API page for field types, widgets and formatters still shows annotations. All three sources remain right about the class structure and the method names.

  • The same book, and tutorials of its time, put hook implementations in .module files. Plugins are unaffected, but any glue code that formerly lived in hooks now belongs in src/Hook classes (Drupal 11.1 and later). See Where older books and tutorials are outdated.

Summary

  • A block, a field type, a widget and a formatter are each a class in a fixed Plugin namespace with an attribute and a base class. Use the attribute classes, not annotations.

  • A block describes its output with cache tags, contexts and max-age, checks access to anything it shows and stores its settings in configuration that needs schema.

  • A field type owns the properties, constraints and database columns; the widget and formatter only shape input and output. Treat the column set of a field type as final once data exists.

  • Plugin IDs are part of stored configuration: never rename them casually.

  • Build with constructor injection, escape every editor-supplied value and test the type with a kernel test.