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 |
|
|
|
Field type |
|
|
|
Field widget |
|
|
|
Field formatter |
|
|
|
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.
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) |
|---|---|
|
|
|
|
|
|
|
|
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.
A block plugin: the featured object
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;
}
}
Source: Block API overview
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 mergeparent::defaultConfiguration(), which holds the cache settings and the label-display flag. -
blockForm(),blockValidate()andblockSubmit()build, validate and store the settings. Anentity_autocompleteelement returns the entity ID when the target type is set; the#default_valuemust 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 anAccessResultthat carries cache metadata;allowedIfHasPermission()adds theuser.permissionscache 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()andgetCacheMaxAge()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 ingetCacheTags()too also covers the case wherebuild()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 withCacheableMetadata. -
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 of0makes 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.
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'
Source: Configuration schema/metadata
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_typeslimits where the formatter is offered; herestring. For formatters that apply to a single field only, overrideisApplicable()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()andsettingsSummary()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.*) undersettings, 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 beforeviewElements(). 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 (RangeandAllowedValuesare 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. Itscolumnsbecome columns of thecollection_object__dimensionstable (named from the entity type and field, one row per delta per revision per language), prefixed by the field name, for exampledimensions_height.indexesandunique keysadd 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 isstaticbecause Drupal needs the schema before any value exists, when it installs the storage; it receives the storage definition so that storage settings (herescale) can shape the columns. -
defaultStorageSettings()anddefaultFieldSettings()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_datais for. Field settings belong to the field instance on a bundle and can change at any time. Both are exported as configuration (field.storage.*andfield.field.*), so both need schema.
The remaining methods:
-
isEmpty()decides whether the item counts as a value. Items for which it returnsTRUEare removed from the list on save, which is why a form submitted with every number blank stores nothing.FieldItemBaseinherits a default fromMap, 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 bydrush devel-generate; it also helps tests. -
storageSettingsForm()andfieldSettingsForm()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'
Source: Configuration schema/metadata
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
Source: Drush: cache:rebuild
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());
}
}
Source: PHPUnit in Drupal
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_textand#markupwith a translatable string are escaped;#markupwith a raw value is filtered by an XSS filter that allows a small set of tags, andMarkup::create()or|rawin Twig turns filtering off. Editor-supplied values (accession numbers, dimensions, titles) go through#plain_textor a text format, never throughMarkup::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 stateaccessCheck(TRUE)(orFALSEwith a written reason); the call is mandatory in current Drupal versions. -
Validate on the server. The
#minof 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 inmeridian_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.phpor 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/FieldWidgetand 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 declaresdepth; 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; withoutmassageFormValues()the database gets a string in a numeric column (an error on PostgreSQL) orisEmpty()returns the wrong answer. -
Services fetched statically.
\Drupal::entityTypeManager()insidebuild()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
categoryis deprecated. -
OOP hooks (
#[Hook], Drupal 11.1) and plugin attributes are independent features; hook classes live insrc/Hookand plugins insrc/Plugin. A module can use both. Field plugins need no hook. See Hooks and OOP hooks. -
FormatterBase::create()autowires throughAutowiredInstanceTrait; setter injection with#[Required]arrived in 11.4.0. -
Settings Tray, which used the block
formsargument 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,@FieldWidgetand@FieldFormatterand 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
.modulefiles. Plugins are unaffected, but any glue code that formerly lived in hooks now belongs insrc/Hookclasses (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
Pluginnamespace 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.
For the neighbouring topics see Field types and entity references, Entity API, Form API and Plugin API and attributes.