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'— theipaddressfield type, used by A and AAAA.'target'— string field, used by CNAME, MX, NS, PTR, SRV, HTTPS, SVCB (in core) and DNAME (in thedns_extrassubmodule). All of these composeDrupal\dns\Plugin\RecordType\TargetHostnameTraitso 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:
- The standard
targetbase field for the mail-server hostname. dns_record_rdata_valuewith keypriorityfor the MX priority.- The standard
Operationsfield 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.