Skip to content

Writing a provider plugin

A Provider plugin is a Drupal plugin that integrates one external DNS service (ClouDNS, Route 53, Cloudflare, …) with the dns_external framework. The plugin manager discovers plugins via the #[\Drupal\dns_external\Attribute\Provider] attribute on classes under any module's Plugin/Provider/.

This page walks through the contract, the value-object normalization rules, and the capability model.

Module layout

A provider integration is its own Drupal module — dns_cloudns, dns_route53, etc. The framework lives in dns_external; concrete providers ship separately so each can pull in its own SDK / API client / Composer dependencies without polluting the framework's dependency surface.

dns_cloudns/
├── dns_cloudns.info.yml
├── composer.json                  # SDK dependency, peer-dep on dns_external
└── src/
    └── Plugin/
        └── Provider/
            └── ClouDnsProvider.php

Declare dns_external as a hard dependency in info.yml:

dependencies:
  - dns_external:dns_external

The contract

Every provider plugin implements \Drupal\dns_external\Plugin\Provider\ProviderInterface (extend ProviderBase in practice — it stubs every optional method with a sensible default or a loud LogicException for undeclared operations).

The contract is grouped:

// 1. Identity & capabilities.
public function capabilities(): array;
public function zoneTypes(): array;
public function label(): TranslatableMarkup;

// 2. Configuration.
public function buildSettingsForm(...): array;
public function validateSettingsForm(...): void;
public function submitSettingsForm(...): void;
public function buildZoneTypeForm(...): array;

// 3. Discovery / import.
public function listRemoteZones(ProviderConfigInterface $config): iterable;
public function fetchZone(ProviderConfigInterface $config, string $remote_id): RemoteZone;
public function fetchRecords(ProviderConfigInterface $config, string $remote_id): iterable;

// 4. Export / push.
public function pushZone(...): string;
public function pushZoneUpdate(...): void;
public function pushZoneDelete(...): void;
public function pushRecord(...): string;
public function pushRecordUpdate(...): void;
public function pushRecordDelete(...): void;

Implement only the groups you actually support — declare the matching Capability cases, and ProviderBase's LogicException defaults will catch any wiring mistake immediately.

Declaring capabilities

capabilities() is the source of truth for what the admin UI shows. Return a subset of \Drupal\dns_external\Provider\Capability::cases():

use Drupal\dns_external\Provider\Capability;

public function capabilities(): array {
  return [Capability::Import, Capability::Export];
}

Cases:

  • Capability::Import — discover, fetch zones, fetch records.
  • Capability::Export — push zones, push records (create + update + delete).
  • Capability::ZoneTypes — provider has per-zone variants (master/slave/geo/parked); zoneTypes() and buildZoneTypeForm() are required.
  • Capability::Sync — reserved for the bidirectional-sync pipeline shipping later. Don't claim it yet.

RemoteZone / RemoteRecord — the normalization contract

Provider plugins never hand provider-specific API responses back to the framework. Discovery and fetch return value objects:

  • \Drupal\dns_external\Provider\RemoteZone
  • \Drupal\dns_external\Provider\RemoteRecord

The importer consumes only these objects, so a foreign API's representation never leaks into the import pipeline. The class docblocks have the full normalization rules; the high points:

RemoteZone

Field Rule
remoteId The provider's identifier for this zone.
name Lowercase Punycode (ACE) form. Use idn_to_ascii() if the upstream returns Unicode.
data Free-form bag round-tripped into the binding's provider_data JSON. Zone type for providers with Capability::ZoneTypes, geo-routing settings, etc.

RemoteRecord

Field Rule
remoteId The provider's identifier for the record.
type Uppercase DNS token (A, AAAA, MX, …). The importer skip-and-logs types it doesn't natively support — pass through, don't filter.
prefix Empty string for the zone apex. Not @, not the zone name, not a trailing dot. Lowercased per RFC 1035 §2.3.3.
ttl Integer seconds. Use 3600 if the provider doesn't surface one.
ipAddress Plain IPv4/IPv6 string for A/AAAA only; NULL otherwise.
target BIND zone-file form for CNAME/MX/NS/PTR/SRV/HTTPS/SVCB/DNAME. Trailing dot for absolute (mail.example.com.), no dot for relative (www), literal @ for the zone apex.
rdata Per the record-type plugin's contract (see table below).

rdata keys

MX     → ['priority' => int]
SRV    → ['priority' => int, 'weight' => int, 'port' => int]
TXT    → ['content' => string]
CAA    → ['tag' => string, 'value' => string, 'critical' => bool]
HTTPS  → ['priority' => int, 'params' => array<string, mixed>]
SVCB   → same as HTTPS
(A / AAAA / CNAME / NS / PTR carry no rdata.)

Plugins for the dns_extras family (DNAME, SSHFP, TLSA, SMIMEA, OPENPGPKEY, DS, DNSKEY) fill in the relevant payload keys per HashRecordTypeBase's contract.

Settings form — secrets via Key, identifiers in plain config

The settings form contributes whatever fields your plugin needs. Secret material — passwords, API tokens, anything whose leak would constitute a credential compromise — must never live in the provider_config settings blob; reference a Key entity by id and resolve it at API call time.

Non-secret identifiers (account ids, sub-user usernames, region codes, endpoint URLs) belong in plain config. Wrapping a username in a Key entity adds friction without protecting anything.

The ClouDNS plugin is the canonical example: the auth-id (a ClouDNS account-level numeric id or a sub-user identifier — not secret) is a plain textfield setting, and the auth-password (the only actual secret) is the Key reference.

public function buildSettingsForm(array $form, FormStateInterface $form_state, ProviderConfigInterface $config): array {
  $elements = [];

  // Plain identifier — not secret.
  $elements['auth_id'] = [
    '#type' => 'textfield',
    '#title' => $this->t('Auth-id'),
    '#default_value' => (string) $config->getSetting('auth_id', ''),
    '#required' => TRUE,
  ];

  // Secret — Key reference.
  $elements['api_password_key'] = [
    '#type' => 'key_select',
    '#title' => $this->t('API password Key'),
    '#default_value' => $config->getSetting('api_password_key', ''),
    '#required' => TRUE,
  ];

  return $elements;
}

At runtime:

$auth_id = (string) $config->getSetting('auth_id');
$key = \Drupal::service('key.repository')->getKey((string) $config->getSetting('api_password_key'));
$secret = $key->getKeyValue();

Push semantics — what the framework expects

The pusher walks records in stable order and, per record:

  • If the record has no provider_remote_id → calls pushRecord(), stamps the returned id back on the local entity.
  • If the record has a provider_remote_id → calls pushRecordUpdate(). Don't create duplicates — if the remote id is stale, throw, and the framework will record it as a per- record failure.

pushZone() is called once per binding, on the first push (when provider_remote_id on the binding is empty). Subsequent pushes treat the remote zone as already-existing and only touch records. pushZoneUpdate() is reserved for an admin-driven re-push of zone- level settings — the v1 push UI doesn't call it; provider modules can expose a separate action if they need to.

Per-record failures are non-fatal at the zone level — throw your exception and the pusher records it in PushResult::$failures so the admin sees the count and the per-record reason. A re-run picks up where the failure left off.

Reference: dns_cloudns

The first concrete provider lives in its own module, dns_cloudns. It exercises the full contract — discovery, import, push, plus the four ClouDNS zone types via Capability::ZoneTypes — and is the recommended template for new integrations. See the dns_cloudns project page for the latest version.

Test fixture

dns_external ships a FixtureProvider plugin under tests/modules/dns_external_test/ that returns canned data driven by Drupal state — pre-seed state->set('dns_external_test.zones', [...]) and state->set('dns_external_test.records', [...]) to exercise importer or pusher paths in your own tests without standing up a real provider account.