Drupalwoo ☉

Building a custom Drupal field type and formatter

Drupal’s Field API separates content storage, editing and presentation into replaceable plugins. A field type defines what Drupal saves, a widget supplies the edit control, and a formatter turns stored values into render arrays. This separation lets editors manage structured content while developers control database schema, validation and front-end output.

A custom field earns its maintenance cost when a site needs semantics that core fields do not provide. Plain text can hold a label, but it does not consistently carry presentation classes, validation rules or relationships across content types. The goal is a small, documented plugin with predictable storage and upgrade behaviour, not a one-off replacement for core.

Choosing the field plugin architecture

Begin by mapping responsibilities rather than classes. FieldTypePluginBase owns the SQL schema and field-level settings; FieldWidgetPluginBase handles form input; FieldFormatterPluginBase handles display. Decide whether the value is reusable text, a reference, a complex object or derived data, because each choice changes cardinality, validation and querying. Plugin IDs also enter exported configuration, so changing one after launch can break stored settings.

Check how Drupalwoo field guidance approaches module structure and configuration before creating plugin folders. Keep the module generic, use stable machine names, and avoid depending on a particular content type. A field called coverage should work for a Sydney venue, a Melbourne agency and any future Australian client without embedding site-specific labels in code.

Define cardinality and storage explicitly. A single-value field may hold one varchar; unlimited values create one row per item. Store values needed for filtering or sorting, but calculate inexpensive, deterministic presentation details at display time. Clear boundaries reduce schema changes and make later Drupal upgrades safer.

Creating the field type plugin

Create a custom module with a namespaced plugin directory and enable it on a test site. A minimal coverage.info.yml can target Drupal 10 and 11:

name: Coverage field
type: module
description: Provides a reusable coverage label field.
package: Custom
core_version_requirement: '^10 || ^11'

The directory, module name and PHP namespace must agree. Drupal discovers field type classes under src/Plugin/Field/FieldType. Clear caches after adding a class, and never enable development services on a production website.

The field type class extends FieldTypePluginBase, declares its default widget and formatter, and defines the database schema:

namespace Drupal\coverage\Plugin\Field\FieldType;

use Drupal\Core\Field\FieldTypePluginBase;

/**
 * @FieldType(
 *   id = "coverage",
 *   label = @Translation("Coverage label"),
 *   default_widget = "coverage_text",
 *   default_formatter = "coverage_default"
 * )
 */
class CoverageItem extends FieldTypePluginBase {
  public function schema() {
    return [
      'columns' => [
        'value' => [
          'type' => 'varchar',
          'length' => 255,
          'description' => 'The stored coverage label.',
        ],
      ],
    ];
  }
}

Treat this schema as a public contract. Match its length and nullability to entity validation, and document whether an empty string means unknown or not applicable. If an existing field changes shape, use an update hook or migration; editing schema() alone does not modify an installed database.

Implementing the edit widget

The widget controls how an editor supplies a value without deciding how that value appears on the node. This example supports a single-value text input and limits it to the stored column length:

namespace Drupal\coverage\Plugin\Field\FieldWidget;

use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\Widget\FieldWidgetPluginBase;

/**
 * @FieldWidget(
 *   id = "coverage_text",
 *   label = @Translation("Coverage label"),
 *   field_types = {"coverage"}
 * )
 */
class CoverageText extends FieldWidgetPluginBase {
  public function formElement(FieldItemListInterface $items, $langcode) {
    $element = parent::formElement($items, $langcode);
    $element['value'] = [
      '#type' => 'textfield',
      '#default_value' => $items->getValue(),
      '#maxlength' => 255,
    ];

    return $element;
  }
}

For unlimited values, Drupal invokes the widget for each item delta, so the field’s cardinality controls the number of inputs rather than the widget class. Keep that division intact if another widget needs different controls for each cardinality.

Client-side #maxlength improves usability, but server-side constraints should enforce the same rule. Add a validation callback or field constraint for allowed characters and length, returning translated errors through Drupal’s validation system. For a Brisbane retailer, editor guidance can mention Queensland suburbs such as Fortitude Valley, while a postcode-aware validator rejects 400 instead of 0400.

Preserve accessibility and translation. Supply a useful label, description and required-state message instead of relying on a placeholder. Test tab order, screen-reader names, empty values and permissions in every form that exposes the field.

Formatting field output safely

The formatter converts field items into Drupal render arrays. Keeping this logic separate from the widget lets administrators alter display settings without rewriting stored data or the edit form.

namespace Drupal\coverage\Plugin\Field\FieldFormatter;

use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\Formatter\FieldFormatterPluginBase;

/**
 * @FieldFormatter(
 *   id = "coverage_default",
 *   label = @Translation("Coverage label"),
 *   field_types = {"coverage"}
 * )
 */
class CoverageFormatter extends FieldFormatterPluginBase {
  public function viewElements(FieldItemListInterface $items, $langcode) {
    $elements = [];

    foreach ($items as $delta => $item) {
      $elements[$delta] = [
        '#type' => 'inline_template',
        '#template' => '<span class="coverage-label">{{ label }}</span>',
        '#context' => ['label' => $item->getValue() ?: ''],
      ];
    }

    return $elements;
  }
}

The template receives a normal field value and lets Twig escape it. Never pass untrusted text to #markup, because that bypasses contextual escaping and can create cross-site scripting vulnerabilities. If markup needs links or icons, build a dedicated render array with sanitised URLs, accessible text and appropriate cache metadata.

Add display settings through settingsForm(), settingsSummary() and getSettings() only when editors genuinely need them, such as choosing a label style or adding a prefix. Keep field data semantic and put presentation in CSS or theme templates where practical. Guard against missing values, multi-value fields and translated labels so one malformed item cannot break the entire view.

Testing and operating the field plugin

Test through real content forms rather than only calling plugin methods. Create the field on a non-production site, configure its cardinality, place it on a content type, save a node, reload it and inspect both edit and display output. Automate the baseline with a Kernel test for plugin discovery and schema, plus a Functional test for the form-to-render path. The functional checks should cover:

Operational checks should include code review, database backups and rollback planning. Batch imports deserve particular attention because a formatter or queue worker can retain more objects than expected; the techniques in memory-efficient log processing provide a useful model for streaming diagnostics rather than accumulating them. Deployment work should cover:

The responsibilities can be reviewed across the complete content path:

Concern Field type plugin Widget plugin Formatter plugin
Main responsibility Defines storage schema Collects editor input Produces cached render output
Key method schema() formElement() viewElements()
Typical failure Database or upgrade mismatch Invalid or inaccessible input Unsafe markup or poor performance
Change policy Treat as a contract Validate strictly Escape and test every theme

For a Melbourne retail site, acceptance may include Australian-dollar prices with 10% GST separated clearly, dates displayed as 7 September 2026 and reliable timezone handling. Where personal information passes through imports or logs, assess applicable obligations under the Privacy Act 1988 and the Australian Privacy Principles. Clear plugin boundaries and local operational checks give the field a reliable lifecycle beyond its first content type.