Custom Plugin Development
When to Use
You need to create custom field type, widget, or formatter plugins specific to your application.
Pattern
Custom field type plugin:
<?php
namespace Drupal\my_module\Plugin\CustomField\FieldType;
use Drupal\Component\Utility\Random;
use Drupal\custom_field\Attribute\CustomFieldType;
use Drupal\custom_field\Plugin\CustomFieldTypeBase;
use Drupal\custom_field\Plugin\CustomFieldTypeInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
#[CustomFieldType(
id: 'my_custom_type',
label: new TranslatableMarkup('My Custom Type'),
description: new TranslatableMarkup('Custom type description'),
category: new TranslatableMarkup('Text'),
default_widget: 'my_custom_widget',
default_formatter: 'my_custom_formatter',
)]
class MyCustomType extends CustomFieldTypeBase {
public static function schema(array $settings): array {
return [
$settings['name'] => [
'type' => 'varchar',
'length' => 255,
'not null' => FALSE,
],
];
}
public static function propertyDefinitions(array $settings): mixed {
$properties[$settings['name']] = DataDefinition::create('string')
->setLabel(new TranslatableMarkup('Custom value'));
return $properties;
}
public function checkEmpty(): bool {
return TRUE; // Affects field isEmpty() check
}
public static function generateSampleValue(CustomFieldTypeInterface $field, string $target_entity_type): mixed {
return (new Random())->word(10);
}
}
schema(), propertyDefinitions() and generateSampleValue() are static -- declaring them as instance methods breaks discovery. checkEmpty() is the odd one out and is a normal instance method. The attribute lives in Drupal\custom_field\Attribute\, not under Plugin\CustomField\FieldType\Attribute\. column_groups is a real attribute parameter but the shipped field types do not use it -- UI grouping comes from category.
Custom widget plugin (attribute: Drupal\custom_field\Attribute\CustomFieldWidget):
#[CustomFieldWidget(
id: 'my_custom_widget',
label: new TranslatableMarkup('My Custom Widget'),
description: new TranslatableMarkup('Widget description'),
field_types: ['my_custom_type'],
)]
class MyCustomWidget extends CustomFieldWidgetBase {
public function widget(FieldItemListInterface $items, int $delta, array $element, array &$form, FormStateInterface $form_state, CustomFieldTypeInterface $field): array {
$element = parent::widget($items, $delta, $element, $form, $form_state, $field);
$element['#type'] = 'textfield';
$element['#default_value'] = $items[$delta]->{$field->getName()} ?? '';
$element['#maxlength'] = 255;
return $element;
}
}
Custom sub-field formatter plugin -- there is no #[CustomFieldFormatter] attribute. Sub-field formatters are annotated with core's Drupal\Core\Field\Attribute\FieldFormatter; CustomFieldFormatterManager passes that class as its attribute class and scans /Plugin/CustomField/FieldFormatter/. All 33 shipped formatters do this.
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\custom_field\Plugin\CustomFieldFormatterBase;
#[FieldFormatter(
id: 'my_custom_formatter',
label: new TranslatableMarkup('My Custom Formatter'),
field_types: ['my_custom_type'],
)]
class MyCustomFormatter extends CustomFieldFormatterBase {
public function formatValue(FieldItemInterface $item, mixed $value): mixed {
if (empty($value)) {
return NULL;
}
return $this->t('Value: @value', ['@value' => $value]);
}
}
The method is formatValue(FieldItemInterface $item, mixed $value): mixed -- it takes the parent field item and the raw sub-field value, and returns a value (string, markup, or render array), not necessarily a render array.
Decision
| To extend... | Create... | Attribute | Base class |
|---|---|---|---|
| New field type (column storage) | CustomFieldType plugin | Drupal\custom_field\Attribute\CustomFieldType |
CustomFieldTypeBase |
| New widget for existing/custom type | CustomFieldWidget plugin | Drupal\custom_field\Attribute\CustomFieldWidget |
CustomFieldWidgetBase |
| New formatter for existing/custom type | Sub-field formatter plugin | core's Drupal\Core\Field\Attribute\FieldFormatter |
CustomFieldFormatterBase |
| Map a sub-field onto an SDC component prop | PropWidget plugin (5.x, in src/Plugin/Components/PropWidget/) |
Drupal\custom_field\Attribute\PropWidget |
Drupal\custom_field\Plugin\PropWidgetBase / PropWidgetInterface -- in src/Plugin/, one level above the plugin directory |
The prop-widget type is the fourth extension point, added in 5.x and discovered by plugin.manager.custom_field_component_prop_widget. It is what the custom_field_sdc formatter uses to decide how each sub-field feeds a component prop.
Form element: 5.x also ships #[FormElement('custom_field_multivalue')] (src/Element/MultiValue.php) -- the multi-value wrapper element, reusable from your own forms.
Event, not a plugin -- the cheapest extension point in the module. PreFormatEvent (src/Event/PreFormatEvent.php) is dispatched by BaseFormatter::getFormattedValues() and by SingleDirectoryComponentFormatter, immediately before every field-level formatter renders. A subscriber receives the sorted CustomFieldTypeInterface sub-items, the parent FieldItemInterface and the langcode, and can call setCustomItems() to reorder, filter or swap them. Use it when you need to change which sub-fields render or in what order, across all seven field-level formatters at once -- writing a formatter plugin for that is more work than it is worth.
Legacy annotations: src/Annotation/ still carries CustomFieldType, CustomFieldFeedsType and PropWidget annotation classes beside the src/Attribute/ versions. Write new plugins with the PHP attributes from Drupal\custom_field\Attribute\; the annotation classes exist for back-compatibility and importing one by mistake gives you a plugin that is discovered but not configured the way you expect.
Common Mistakes
- Reaching for a
#[CustomFieldFormatter]attribute -- It does not exist in any version. Sub-field formatters use core's#[FieldFormatter]; only the directory and base class are custom_field's - Writing a
format()method on a formatter -- The base class method isformatValue(FieldItemInterface $item, mixed $value). Aformat()method is simply never called - Declaring schema()/propertyDefinitions()/generateSampleValue() as instance methods -- All three are static on
CustomFieldTypeBase; onlycheckEmpty()is an instance method - Importing the attribute from the plugin namespace -- Attributes live in
Drupal\custom_field\Attribute\, notDrupal\custom_field\Plugin\CustomField\FieldType\Attribute\ - Wrong plugin namespace -- Must be under
/Plugin/CustomField/{FieldType|FieldWidget|FieldFormatter}/ - Not declaring field_types in widget/formatter -- The attribute must list compatible field types
- Forgetting to clear cache -- Plugin discovery is cached;
drush crafter creating plugins - Not handling empty values -- Always check for NULL/empty in
formatValue() - Importing an
Annotationclass instead of theAttributeone --src/Annotation/exists only for back-compatibility; new plugins usesrc/Attribute/
See Also
- SDC View-Mode Rendering -- the
custom_field_sdcformatter that consumes#[PropWidget]plugins - Reference: Extending Custom Field formatter plugins
- Reference:
/modules/contrib/custom_field/src/Plugin/CustomFieldTypeBase.php