Skip to content

Architecture

This page is for developers integrating with the DNS module from another module, or extending it via record-type plugins. It assumes familiarity with Drupal's content entity API, plugin system, and Views.

Module structure

Single module, no submodules. Everything lives under dns/src/:

dns/
├── composer.json
├── dns.info.yml
├── dns.module                 # hooks (help, ENTITY_TYPE_view, extra fields)
├── dns.permissions.yml
├── dns.routing.yml
├── dns.links.menu.yml
├── dns.links.action.yml
├── dns.links.task.yml
├── dns.services.yml           # plugin manager service definitions
├── dns.views.inc              # views integration (rdata field handler)
├── config/install/            # default config (dns.settings, the dns_records view)
└── src/
    ├── Attribute/             # plugin attributes
    ├── Entity/                # DnsZone, DnsRecord
    ├── Form/                  # zone form, record form, settings form
    ├── Plugin/                # local-action, RecordType plugins, validation constraints, views handlers
    ├── Routing/               # custom route providers
    ├── Utility/               # ZoneNameTransformer (static helpers)
    └── *.php                  # interfaces, access handlers, list builders, plugin managers

The D7 split (dns + dns_zone + dns_record) is gone. Zones without records is too niche to justify the maintenance burden, and records without zones doesn't make sense. One module, one upgrade path.

Entity model

DnsZone

Content entity, base table dns_zone. Two notable design decisions:

  1. The name field is the entity ID. entity_keys.id = "name", string PK. Punycode form. Stable URL slug, cache-tag identifier, and what DNS itself sees on the wire. setReadOnly(TRUE) so storage rejects rename attempts after first save.
  2. The label field is the Unicode display form. entity_keys.label = "label". ASCII zones have name === label; IDN zones differ. $entity->label() returns Unicode; every Drupal subsystem that asks for a label (page titles, autocomplete, Views' default Label handler, JSON:API) renders Unicode for free.

Other base fields: uid (owner), created, changed, attrs (string_long JSON for arbitrary key/value attributes — currently hidden from form/view, kept for forward compat).

Zone names round-trip through Drupal\dns\Utility\ZoneNameTransformer::derivePair(), which accepts either Unicode or already-Punycode input and returns the canonical [label, name] pair. The transformer is a static utility class — zero state, zero deps, deterministic. Aligned with Drupal core's Component/Utility/* convention.

Validation lives on the label field as Drupal\dns\Plugin\Validation\Constraint\ZoneNameConstraint / ZoneNameConstraintValidator. The validator normalizes (trim → strip trailing dot → idn_to_ascii UTS-46 → lowercase) and then checks RFC 1035 structure plus uniqueness against the canonical Punycode form (so münchen.de and xn--mnchen-3ya.de collide correctly).

DnsRecord

Content entity, base table dns_record. entity_keys.id = "record_id" (integer autoincrement). Records are nested under their zone in the URL space (/dns/{dns_zone}/records/{dns_record}) via a custom route provider, Drupal\dns\Routing\DnsRecordRouteProvider, which adds the dns_zone parameter converter to all routes generated from the entity link templates.

Base fields:

Field Type Used by
record_id integer (entity ID) always
uuid uuid always
zone entity_reference (DnsZone) always (required)
prefix string always
record_type list_string (plugin id) always
ttl integer (unsigned) always
uid entity_reference (User) always
ip_address ipaddress (field_ipaddress) A, AAAA
target string (max 253) CNAME, NS, MX, PTR, SRV (and future DNAME)
rdata string_long (JSON) type-specific tail
created, changed created / changed timestamps always

The shared base fields (ip_address, target) are each used by multiple record types and earn their column slot. Per-type data that's narrow or rarely queried (priority, weight, port, content, SSHFP fingerprints, etc.) goes in the rdata JSON catch-all. The threshold for promotion is shared across at least two record types AND the cross-type query is operationally meaningful. priority (MX + SRV) doesn't clear the bar because the queries are always within a single type.

The record_type field uses allowed_values_function pointing at DnsRecord::recordTypeAllowedValues(), which queries the RecordType plugin manager — so adding a new record-type plugin automatically adds it to the dropdown without further config.

The ipaddress field is configured with allow_family = IP_FAMILY_ALL and allow_range = FALSE at the storage level. The field's widget enforces parse-validity and single-only. Family is a plugin-level concern (only the active record-type plugin knows whether IPv4 or IPv6 is required), so SingleAddressRecordTypeBase performs that check in its validateForm().

RecordType plugin system

Plugins live under Drupal\dns\Plugin\RecordType\, discovered via the #[\Drupal\dns\Attribute\RecordType] PHP attribute. The manager is Drupal\dns\RecordTypeManager (service id plugin.manager.dns_record_type).

The interface, Drupal\dns\Plugin\RecordType\RecordTypeInterface, has five methods:

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;

Drupal\dns\Plugin\RecordType\RecordTypeBase provides no-op defaults for the optional contract methods. SingleAddressRecordTypeBase specializes for record types that store one IP and is the parent of A and AAAA. CName extends RecordTypeBase directly since it claims target instead of ip_address.

For step-by-step instructions on writing a new record-type plugin, see Writing a record type.

Cross-type entity constraints

Some DNS rules can't live inside a single plugin because they cross record types. They're attached as constraints in the DnsRecord entity annotation and fire on every record's validation.

  • DnsRecordCnameExclusivity (RFC 2181 §10.1) — a name with a CNAME cannot have any other record. The validator queries siblings at the same (zone, prefix) (apex-equivalence applied: empty string and @ prefix collapse) and rejects two cases: saving a CNAME alongside any existing record, and saving any non-CNAME record alongside an existing CNAME.

When future cross-type rules surface (NS-at-zone-cut requirements, DNSKEY/DS pairing, etc.) they follow the same shape: an attribute- discovered constraint plugin attached to the entity, with a validator that loads siblings via storage.

Target hostname normalization

The target shared field stores values in canonical form (lowercase Punycode) for any record type that uses it. Normalization happens in DnsRecord::preSave() — entity-level so it applies regardless of write path (form, Migrate, REST/JSON:API, programmatic). Display helpers convert back to Unicode at render time via ZoneNameTransformer::toUnicode().

Plugin form-validate of the user-typed value uses ZoneNameTransformer::derivePair() followed by validateLabels() — both pure functions, reusable across all target-using record types.

The LabelValidationError enum returned by validateLabels() is the typed contract between the structural validator and its consumers; plugins map enum cases to their own localized messages.

Form flow on record submit

The record form (Drupal\dns\Form\DnsRecordForm) auto-renders all configurable base fields via the standard form display, then:

  1. Sets #access = FALSE on shared fields the active plugin doesn't claim (e.g. an A record hides target).
  2. If the plugin contributes additional rdata fields via buildForm(), places them under a fieldset keyed RecordTypeBase::FORM_KEY_RDATA.
  3. Wraps the whole form in a wrapper div and attaches #ajax to the record_type widget — changing the type rebuilds the entire form so the toggleable widgets appear in their new state.

On submit:

  1. parent::validateForm() runs entity-level validation (constraints, field-level required, etc.).
  2. The form re-runs buildEntity() to get the form-populated entity (parent's validateForm works on a clone but doesn't update $this->entity — that happens in submitForm()). The plugin's validateForm() runs against the populated entity.
  3. parent::submitForm() updates $this->entity via buildEntity().
  4. save() clears any shared fields the active plugin doesn't claim (so switching A → CNAME doesn't leave a stale IP), calls applyToRecord() for any plugin-side persistence work, and delegates to parent::save().

Views integration

A default view views.view.dns_records ships in config/install with three displays:

  • default — master.
  • page_records — page display at /admin/content/dns/records, replaces the old hand-rolled EntityListBuilder for the records collection. Admins can customize columns, filters, sorts, exposed filters via the Views UI.
  • embed_zone — embed display, takes the zone id as the contextual filter, used by dns.module's hook_dns_zone_view to render the records list inline on the zone canonical page. Surfaced as a pseudo-field "DNS records" on the zone view display so admins can hide/move/disable it via Manage Display.

For surfacing rdata JSON values as Views columns, Drupal\dns\Plugin\views\field\RdataValue is a configurable field handler — admins add it to a view, set the JSON key in the field options, and it renders that key for each row. No filter or sort handlers; the use case is "show me the full picture as a table." See Customizing the records view.

JSON:API resource URLs

jsonapi (when enabled) addresses resources by UUID, not by the entity's primary id. So zone resources live at /jsonapi/dns_zone/dns_zone/{uuid} rather than /jsonapi/dns_zone/dns_zone/{name}. The Punycode name is exposed as attributes.drupal_internal__name in the response payload, and record-to-zone relationships expose the target zone's id as attributes.drupal_internal__target_id.

This is core JSON:API behavior; no module-level code influences it. Consumers wanting to look up a zone by name do so via filtering:

GET /jsonapi/dns_zone/dns_zone?filter[drupal_internal__name]=example.com

Field-level serialization of ip_address exposes all three properties — ip_start, ip_end (both binary blobs), and value (the human-readable IP). Use value. The binary properties are the storage shape and shouldn't be relied on as a stable API surface; consider them implementation detail.

Shipped record types

The main module ships the record types that cover the vast majority of real zones — A, AAAA, CNAME, NS, MX, TXT, CAA, SRV, PTR — plus the modern service-binding pair HTTPS and SVCB (RFC 9460) once alpha2 lands. Targets-only types (CNAME / NS / PTR / SRV / HTTPS / SVCB) all share the TargetHostnameTrait so the BIND-format rules don't drift between plugins. Cross-type rules (currently CNAME exclusivity) live on the entity, not the plugin.

The dns_extras submodule (see roadmap) carries the smaller-audience types that share core's plugin contract but don't earn shelf space in the average install — DNAME plus the hash-digest family (SSHFP, TLSA, SMIMEA, OPENPGPKEY, DS, DNSKEY).

External providers (dns_external)

The optional dns_external submodule is the framework half of a two-sided design: it provides the plugin manager, value objects, binding entity, importer, and pusher; concrete provider integrations (dns_cloudns, future dns_route53, …) ship as separate modules and register Provider plugins via #[\Drupal\dns_external\Attribute\Provider]. Splitting concrete providers out of the framework keeps each one's SDK / Composer dependency surface scoped to that integration.

modules/dns_external/
├── src/
│   ├── Attribute/Provider.php             # plugin attribute
│   ├── Plugin/Provider/                   # contract + base class
│   │   ├── ProviderInterface.php
│   │   └── ProviderBase.php
│   ├── Provider/                          # value objects
│   │   ├── RemoteZone.php
│   │   ├── RemoteRecord.php
│   │   ├── ProviderConfigInterface.php
│   │   └── Capability.php
│   ├── Entity/
│   │   ├── ProviderConfig.php             # config entity (account)
│   │   └── DnsExternalBinding.php         # content entity (zone↔account)
│   ├── Importer/Importer.php              # discovery + materialize
│   ├── Pusher/Pusher.php                  # local → remote
│   └── ProviderManager.php
└── tests/modules/dns_external_test/       # FixtureProvider for tests

Storage. provider_config is a config entity (one row per provider account, settings blob). dns_external_binding is a content entity (one row per local zone, unique on zone, carrying the provider's id for the zone plus a free-form provider_data map). Records carry their own provider_remote_id directly as a base field added to dns_record via hook_entity_base_field_info() — one nullable column per row, no per-record pivot entity. Multi-provider mirroring isn't a v1 goal; the user explicitly opted into the column over a pivot entity.

Credentials. The Key module is a hard dependency. Provider plugins store credential references as Key entity ids in their settings blob, never raw secrets. Plugins resolve the key at API call time via the key.repository service.

RemoteZone / RemoteRecord are the normalization target: every provider plugin's discovery / fetch methods convert upstream responses into these readonly value objects, and the importer consumes only those. A foreign API's representation never leaks past the plugin boundary, so adding a new provider doesn't touch the import pipeline.

Importer (dns_external.importer) is synchronous and per-zone. The full materialize phase runs inside a database transaction; mid-flight failures roll back so a half-imported zone never lingers. Pre-flight refuses when a local zone with the same name already exists (re-import / merge ships with bidirectional sync). Per-record skips for unsupported types or validation failures are non-fatal — they accumulate into ImportResult and log to the dns_external channel rather than aborting the zone.

Pusher (dns_external.pusher) walks the local zone state and dispatches pushRecord (record has no provider_remote_id) or pushRecordUpdate (it does). The first push of an unbound zone fires pushZone once and stamps the returned id on the binding; subsequent pushes only touch records. No transaction — remote API calls aren't rollback-able, and the partial-failure mode is real: records that succeeded keep their stamps, failed ones don't, and a re-run picks up where the previous attempt stopped.

Access. Provider configurations and binding mutation are admin-only (administer dns providers). Binding view defers to the parent zone's view records capability so zone owners and granted collaborators see whether their zone is bound, even though they can't change the binding. Per-zone push/import remains admin- only in this version because credentials and remote-API responsibility are admin concerns.

See the plugin-author guide for the full contract and normalization rules.

What's not yet built (and where it goes when it is)

Feature Where it'll live
Bidirectional sync (record-level diff, conflict resolution, deletes propagated either direction) Service alongside dns_external.importer and dns_external.pusher. The unidirectional importer (refuses on local-existing) and pusher (no delete propagation) shipped first to keep the contract small; merge / sync semantics are a separate code path with its own UX.
Live DNS drift detector Service Drupal\dns\DriftDetector resolves each stored record against the live authoritative answer (PHP's dns_get_record() or a shell-out to dig +short for richer types) and reports three categories: missing-at-live (in our DB, not resolving), extra-at-live (resolving, not in our DB — pulled via AXFR or per-type queries), and value-mismatch. Surfaced as a "Compare with live DNS" action on the zone canonical, plus an optional scheduled queue worker that flags drift in the admin overview. Treats CNAME/target rendering, TTL deltas, and the BIND apex/relative resolution rules consistently with how we store them.
dns_extras submodule for less-common record types: DNAME and the hash-digest family (SSHFP, TLSA, SMIMEA, OPENPGPKEY, DS, DNSKEY) Submodule under modules/dns_extras/. Same plugin contract as core. The hash-digest types share a HashRecordTypeBase so each concrete plugin is ~30 lines (header range + payload encoding + label). Shipped separately because the average install never enables DNSSEC self-host or DANE / S/MIME / SSH fingerprint publishing.
Tier-C specialty types (LOC, NAPTR, CERT) Deferred; community-driven. Each is an irregular one-off with its own grammar (LOC's coords, NAPTR's regex/replacement, CERT's superseded-by-TLSA-and-SMIMEA semantics). Anyone with a real use case can contribute via the existing plugin contract.
Provider-quirks third-party modules (e.g. dns_dnsimple for ALIAS, redirect-style records) Out of tree. ALIAS and "Web Redirect"-style records are provider-specific synthetic types, not on-wire DNS — they belong to a provider integration module, not core DNS.
SOA auto-generation per zone Zone-level metadata (serial / refresh / retry / expire / minimum-ttl) emitted at export time; no SOA plugin in the record dropdown — your provider's UI shouldn't either.
SPF builder UI for TXT records A guided composer that produces SPF-valid TXT content from a mechanism list (include / a / mx / ip4 / ip6 / exists / redirect / all + qualifiers). Likely a TXT-plugin form variant or alter, with optional client-side preview matching the BIND-target preview pattern.
IANA TLD list validation New service Drupal\dns\IanaTldList (cached, refreshable). New setting + checkbox in zone-name validator so zone creation rejects names whose effective TLD isn't on the IANA list.
Provider access constraints (read-only zones, records-only-editable, etc.) Provider modules ship their own access policy in the dns_zone scope (see extension-points) that revokes capabilities on provider-managed zones. The local-grant policy ships in alpha3; concrete provider constraint policies layer on top of the dns_external binding entity once a provider needs them.
Migration from 7.x-1.x (full d7_user mapping) Post-1.0. The current shipped migration covers the data side (zones + records, all six D7 types) and defaults zone owners to admin. Wiring user-owner mapping through the standard d7_user migration with a robust fallback when d7_user hasn't been run is the planned follow-up.
Native JSON column for rdata 3.x branch only. Replaces string_long field with json schema column; reuses everything else.

These are roadmap items, not promises — they ship when they ship.