Skip to content

Writing a record type

Record types are plugins. To add a new one — say, an MX plugin for mail-exchanger records — you write a class with the #[\Drupal\dns\Attribute\RecordType] attribute, place it under src/Plugin/RecordType/, and the plugin manager picks it up.

This page walks through the contract and shows two reference implementations.

The contract

Every record-type plugin implements \Drupal\dns\Plugin\RecordType\RecordTypeInterface:

public function usedFields(): array;
public function buildForm(array $form, FormStateInterface $form_state, DnsRecordInterface $record): array;
public function validateForm(array &$form, FormStateInterface $form_state, DnsRecordInterface $record): void;
public function applyToRecord(DnsRecordInterface $record, array &$form, FormStateInterface $form_state): void;
public function renderSummary(DnsRecordInterface $record): string;

RecordTypeBase provides no-op defaults for the optional methods, so in practice you usually override usedFields(), renderSummary(), and one or two of the rest.

usedFields()

Returns the set of shared base fields this record type claims. Recognized values:

  • 'ip_address' — the ipaddress field type, used by A and AAAA.
  • 'target' — string field, used by CNAME, MX, NS, PTR, SRV, HTTPS, SVCB (in core) and DNAME (in the dns_extras submodule). All of these compose Drupal\dns\Plugin\RecordType\TargetHostnameTrait so the BIND zone-file rules don't drift between plugins.

The form auto-renders all base fields and hides the ones not in this list when the user picks your plugin's record type.

A plugin that stores everything in the long-tail rdata JSON field returns an empty array.

buildForm()

Returns additional form elements for type-specific data that lands in rdata. Returned elements are placed under a fieldset keyed by RecordTypeBase::FORM_KEY_RDATA so plugin validators can reach them via $form_state->getValue([RecordTypeBase::FORM_KEY_RDATA, …]).

Plugins whose data fits entirely into shared base fields return an empty array.

validateForm()

Plugin-specific validation that runs after the field widgets have already validated. The entity is form-populated by this point — read fields off $record directly.

Use this for cross-cutting checks the field-level constraints can't express on their own. Examples:

  • A plugin asserts the IP family (the field accepts both, the plugin tightens to one).
  • CNAME plugin validates the target hostname's syntax and length.

applyToRecord()

Last-write-wins entity mutation at submit time. The form pre-clears rdata to empty before calling this, so plugins that contribute long-tail data write their full payload via $record->setRdata(…).

Plugins that only use shared base fields don't need to override this — the no-op default is correct.

Don't put target hostname normalization here — it belongs in DnsRecord::preSave() so it applies on every write path, not just the form.

renderSummary()

Returns a single-line human display of the record's value. Used in the records list view's "Value" column. Don't include the prefix, type, or TTL — only the type-specific payload.

For Punycode-stored values (target), call ZoneNameTransformer::toUnicode() so admin views see the friendly form.

Worked example 1: A (single IPv4 address)

A is the smallest possible plugin — it's three lines of override on top of SingleAddressRecordTypeBase, which is itself the specialization for "single IP, family-checked":

#[RecordType(
  id: 'A',
  label: new TranslatableMarkup('A (IPv4 address)'),
  description: new TranslatableMarkup('Maps a hostname to a single IPv4 address.'),
)]
final class A extends SingleAddressRecordTypeBase {
  protected function expectedFamily(): int {
    return IpAddress::IP_FAMILY_4;
  }
  protected function familyLabel(): string {
    return 'IPv4';
  }
}

AAAA is the same shape with IP_FAMILY_6 / 'IPv6'.

SingleAddressRecordTypeBase claims ip_address, runs the family check via byte-length detection of ip_start, and rejects exact duplicates (same zone + prefix + IP). Read it as a reference for any record type that wants those features.

Worked example 2: CNAME (target hostname)

CName extends RecordTypeBase directly. It claims target, validates the hostname structure, and renders the target back in Unicode for display:

#[RecordType(
  id: 'CNAME',
  label: new TranslatableMarkup('CNAME (canonical name)'),
  description: new TranslatableMarkup('Aliases this hostname to another hostname.'),
)]
final class CName extends RecordTypeBase {
  public function usedFields(): array {
    return ['target'];
  }

  public function validateForm(array &$form, FormStateInterface $form_state, DnsRecordInterface $record): void {
    // Read the user-typed target from the entity (already populated
    // by the form's auto-render of the `target` base field).
    // Derive the canonical form, run the structural validator, map
    // the enum case to a localized message.
    // …
  }

  public function renderSummary(DnsRecordInterface $record): string {
    $target = (string) ($record->get('target')->value ?? '');
    return $target === '' ? '' : ZoneNameTransformer::toUnicode($target);
  }
}

The cross-type CNAME-exclusivity rule (RFC 2181 §10.1: a name with a CNAME cannot have any other record) is not in this plugin — it's an entity-level constraint (DnsRecordCnameExclusivityConstraint) attached to dns_record. The reasoning: the rule applies when saving any record type, not just CNAME, so it doesn't fit the "this plugin's record" mental model. See the Architecture page.

Adding a record type that uses rdata

The shipped MX plugin shows the typical shape — one shared base field (target) plus type-specific data in rdata:

#[RecordType(id: 'MX', label: …)]
final class Mx extends RecordTypeBase {
  use TargetHostnameTrait;

  public function usedFields(): array {
    return ['target'];
  }

  public function buildForm(array $form, FormStateInterface $form_state, DnsRecordInterface $record): array {
    return [
      'priority' => [
        '#type' => 'number',
        '#title' => $this->t('Priority'),
        '#default_value' => $this->getRdataValue($record, 'priority', 10),
        '#required' => TRUE,
        '#min' => 0,
        '#max' => 65535,
      ],
    ];
  }

  public function validateForm(array &$form, FormStateInterface $form_state, DnsRecordInterface $record): void {
    $this->validateBindTarget($form_state, $record);
    // …range-check rdata.priority via getSubmittedRdata()…
  }

  public function applyToRecord(DnsRecordInterface $record, array &$form, FormStateInterface $form_state): void {
    $values = $this->getSubmittedRdata($form_state);
    $record->setRdata(['priority' => (int) ($values['priority'] ?? 10)]);
  }

  public function renderSummary(DnsRecordInterface $record): string {
    $target = $this->renderBindTarget($record);
    $priority = $this->getRdataValue($record, 'priority');
    return $priority === NULL ? $target : sprintf('%d %s', (int) $priority, $target);
  }
}

For a record type that needs only rdata (no shared base fields), look at the shipped Txt and Caa plugins. For multi-rdata numeric types, look at Srv (priority + weight + port).

Two helpers on RecordTypeBase make rdata access ergonomic: getRdataValue($record, $key, $default) for reads in build/render contexts, and getSubmittedRdata($form_state) for reads in validate/apply contexts.

Surfacing rdata keys in Views

The dns_record_rdata_value Views field handler renders one rdata key per instance — admins add it to a custom view, set the JSON key in the field options. For an MX plugin, an admin building a custom "detailed records" view would add three field instances:

  1. The standard target base field for the mail-server hostname.
  2. dns_record_rdata_value with key priority for the MX priority.
  3. The standard Operations field for edit/delete.

No code change in the plugin or Views integration is needed for new rdata keys.

File layout

src/
  Attribute/RecordType.php                # the attribute
  RecordTypeManager.php                   # the plugin manager
  Plugin/RecordType/
    RecordTypeInterface.php               # the contract
    RecordTypeBase.php                    # no-op defaults
    SingleAddressRecordTypeBase.php       # parent for A / AAAA
    ServiceBindingRecordTypeBase.php      # parent for SVCB / HTTPS
    TargetHostnameTrait.php               # BIND-format target rules
    A.php
    Aaaa.php                              # (class name avoids 4-uppercase)
    Caa.php
    CName.php
    Https.php
    Mx.php
    Ns.php
    Ptr.php
    Srv.php
    Svcb.php
    Txt.php
modules/dns_extras/
  dns_extras.info.yml
  src/Plugin/RecordType/
    HashRecordTypeBase.php                # parent for the digest family
    Dname.php
    Dnskey.php
    Ds.php
    Openpgpkey.php
    Smimea.php
    Sshfp.php
    Tlsa.php

The dns_extras submodule houses the smaller-audience types under the same plugin contract; it's a separate install so the average site doesn't carry plugins it never uses. Tier-C specialty types (LOC, NAPTR, CERT) aren't shipped in either — they remain community-driven contributions per the architecture roadmap.