Writing a Registrar Module

3 views Markdown

A Registrar module connects a domain provider to the automation. It registers, transfers and renews names, and answers every management screen the client area offers for a domain.

Overview

A registrar module extends RegistrarModule and lives in coremio/modules/Registrars/{Name}/{Name}.php. Of the 21 shipped, ExampleRegistrarModule is not a provider but a commented template. Start there: it documents every optional method with its exact return shape.

A domain service is not a hosting service: no server row, no control panel session. The provider is reached over its own API and the core mirrors what it said. Two consequences shape the contract:

  • Everything is keyed off $this->options, not passed as arguments.
  • Almost every method is optional. The core probes with method_exists; a screen whose method is missing is not offered. Ship four methods and grow.

Prerequisites

  • A reseller or test account plus its API credentials. Registrations cost money even in most sandboxes; check what the provider's test mode does before a create.
  • The module skeleton: see Module Anatomy.
  • Instances come from Modules::getInstance('Registrars', 'Acme'), never from new.
  • Copy ExampleRegistrarModule to your own directory and rename the class, the file and the config name together.

Structure

coremio/modules/Registrars/Acme/
Acme.php        the class: extends RegistrarModule
ApiClient.php   the HTTP client, included by hand from initApi()
config.php      ['meta' => [...], 'settings' => ['whois-types' => true, 'dns-record-types' => [...]]]
lang/en.php
lang/tr.php
logo.png

The template splits transport from contract. ApiClient.php speaks HTTP and knows nothing about services; the module class maps the core's vocabulary onto the provider's.

Never build the API client in the constructor

$this->config and $this->service are filled after construction. A client built in __construct() reads empty credentials and fails like a wrong API key. Use a lazy initApi(), called first in every method that talks to the provider.

Walkthrough

Wire the API Client Lazily

initApi()
private function initApi(): void
{
    if ($this->api) return;

    include_once __DIR__ . DS . 'ApiClient.php';

    $this->api = new ApiClient($this->config['settings'] ?? []);

    // Every provider call lands in the module log, which is the only place an
    // operator can see what was actually sent when a registration fails.
    $this->api->logger = fn ($action, $req, $resp) => $this->save_log($action, $req, $resp);
}

Declare Settings and a Connection Test

Declare config_fields($data) and the settings screen builds itself; $data is the saved config['settings']. Add testConnection($config) and the screen grows a button that proves the credentials. Field keys must match config.php.

Implement the Lifecycle

Four methods make a usable module: check(), register(), renew() and sync(). You never write create(): the base owns it and dispatches on the transfer code.

create(), in the base
// coremio/classes/RegistrarModule.php
$hasTcode = !empty($this->options['tcode']);
$method   = $hasTcode ? 'transfer' : 'register';

// A transfer already submitted is not submitted twice: a pending event makes
// create() re-check with Services::check_transfer_status() instead.

foreach (Hook::run('gate:domain.create', $this->service, $this->options, $method) as $veto)
    if (is_string($veto) && $veto !== '') throw new \Exception($veto);

$result = $this->{$method}();          // your method, no arguments

Hook::run('action:domain.created', $this->service, $result, $method);

// A successful transfer does NOT mean an active domain: the base records the
// transfer event and reports the service as still in process.
if ($hasTcode && $result !== false) return ['status' => 'inprocess'];

Publish the TLD Catalogue

Declare tlds() so the operator can import the provider's list with costs, year limits and per-TLD options. cost_prices() is the older, price-only twin, used when tlds() is absent.

Add the Management Surfaces

Each is one method, and each one that exists turns on a control in the client area.

Reference

What the Base Class Gives You

RegistrarModule signatures
// Orchestration: you do NOT override these two
public function create(): array|bool;    // dispatches to register() or transfer()
public function renew(): array|bool;     // wraps renewal() when you declare that instead

// Context
public function set_service(array|int $service = []): void;
public function set_product(array|int $product = []): void;
public function set_order(array|int $order = []): void;
public static function get_doc_lang($param, $lang = '');

// Import and settings screens
public function import_domain($data = []): array;
public function apply_import_tlds($data = []): bool;
public function controller_settings($extraFields = []): array;
public function controller_test_connection(): array;
public function controller_domains(): string;
public function controller_tlds(): string;
public function controller_import(): array;
public function controller_import_tld(): array;

// From ModuleBaseTrait
protected function save_log($action = '', $request = '', $response = '', $processed = ''): int|bool;
protected function encode_str(string $str = '', string $key = ''): string;
protected function decode_str(string $str = '', string $key = ''): string;
public function logo(): string;
create() The entry point the provisioning engine calls. It picks register or transfer, runs the veto hook, and turns a submitted transfer into ['status' => 'inprocess'].
renew() Two shapes: override renew(), as every current module does, or declare renewal() and let the base call it. Not both.
import_domain() Turns one row from domains() into a local service. Your side is domains() and get_info().
save_log() Writes request and response to the module log. Route the API client through it.
$sample_data Set true on a demo module answering from mock data. The status sync skips those modules, so invented expiry dates never overwrite a real due date.

What You Declare

the core four, plus legacy renewal
public function check($sld = null, $tlds = []): array;   // availability, keyed BY TLD
public function register(): array|bool;
public function transfer(): array|bool;
public function renew(): array|bool;
public function sync(): array|false;                     // status + dates from the provider

// Legacy alternative to renew(): declare renewal() and the base calls it instead.
// Zero parameters means it is called bare. With parameters the order is NOT the
// register() one: the options array comes FIRST and there is no $dns/$whois pair.
public function renewal($options, $domain, $sld, $tld, $year, $oduedate, $nduedate);
optional: state, catalogue, management
private function initApi(): void;                        // your own helper, not a core hook
public function config_fields($data = []): array;
public function testConnection($config = []): bool;

public function suspend(): bool;
public function unsuspend(): bool;
public function cancel(): bool;
public function restore(): bool;
public function is_inactive(): bool;
public function transfer_sync(): array|false;
public function get_info(): array|false;

public function domains();                               // provider's domain list, for import
public function tlds();                                  // catalogue with pricing, wins over cost_prices()
public function cost_prices($type = 'domain');

public function save_nameservers(array $dns): true;
public function get_contacts(): array|false;
public function save_contacts(array $whois): bool;
public function get_transfer_lock(): string;             // 'active' | 'passive'
public function toggle_transfer_lock(string $status): bool;
public function get_whois_privacy(): string;
public function toggle_whois_privacy(string $status): bool;
public function get_auth_code(): string|true;            // true = the registrar mailed it

public function get_child_nameservers(): array|false;
public function add_child_nameserver(string $ns, string $ip): array;
public function save_child_nameserver(array $old, string $new_ns, string $new_ip): array;
public function delete_child_nameserver(string $ns, string $ip): bool;

public function get_dns_records(): array|false;
public function add_dns_record($type, $name, $value, $ttl, $priority);
public function update_dns_record($type = '', $name = '', $value = '', $identity = '', $ttl = '', $priority = '');
public function delete_dns_record($type = '', $name = '', $value = '', $identity = '');
public function get_dnssec_records(): array|false;
public function add_dnssec_record($digest, $key_tag, $digest_type, $algorithm);
public function delete_dnssec_record($digest, $key_tag, $digest_type, $algorithm, $identity = '');

public function get_forwarding();
public function set_forwarding($protocol = '', $method = '', $domain = '');
public function cancel_forwarding(): bool;
public function get_email_forwards();
public function add_email_forward($prefix = '', $target = '');
public function update_email_forward($prefix = '', $target = '', $target_new = '', $identity = '');
public function delete_email_forward($prefix = '', $target = '', $identity = '');

public function addon_create(array $addon): bool;
public function addon_suspend(array $addon): bool;
public function addon_unsuspend(array $addon): bool;
public function addon_cancel(array $addon): bool;
The legacy argument shape is easy to mix up

The base inspects your method with Reflection. Zero parameters means it is called bare. With parameters, register() and transfer() get ($domain, $sld, $tld, $year, $dns, $whois, $wprivacy, $eppCode); renewal() gets a different order of seven, options first and no contacts. Every current module uses the zero parameter form and reads $this->options.

What Is on $this->options

domain The full name, in Unicode. Convert with idn_to_ascii() before sending.
name, sld, tld The parts. name is the second level label and sld its legacy alias, so read $this->options['sld'] ?? $this->options['name'] ?? ''.
year The period in years. Fall back to $this->service['period_time'], then to 1.
dns The nameserver list. Pass array_values() of it; providers reject a JSON object where they expect an array.
whois Contacts, keyed registrant, administrative, technical, billing. Shape below.
tcode The transfer authorisation code. Its presence makes the base call transfer() instead of register().
$this->docs Extra documents a TLD demands, declared under settings['doc-fields'][$tld]. A file field holds a path, so base64 the contents. Labels come from get_doc_lang().
$this->addon_params Resolved paid add-ons, for example whois-privacy. Read it to decide whether to buy the provider's equivalent.
one contact, exactly these keys
$contact = $this->options['whois']['registrant'] ?? [];

// FirstName  LastName  Name  Company  EMail
// Country    City      State AddressLine1  AddressLine2  ZipCode
// PhoneCountryCode  Phone  FaxCountryCode  Fax
//
// Country is the two letter ISO code. Name is the joined display form and is
// filled on read; on write, set FirstName and LastName.

What You Return

MethodShapeNotes
check()[tld => ['status' => 'available'|'unavailable']]Keyed by TLD. A premium name adds premium => true and premium_price => ['amount' => float, 'currency' => string]
register()true on successAn array may carry extra service fields; false triggers the failure hook
transfer()true on submissionThe base converts it to ['status' => 'inprocess']; transfer_sync() decides completion
sync()['creationtime', 'endtime', 'status']Dates as Y-m-d; status is active, expired, transferred or unknown
transfer_sync()same keysStatus is only active or pending
get_info()['creation_time', 'end_time', 'ns1'..'ns4', 'whois', 'privacy', 'transferlock']Underscore names differ from sync(). Used by the import
get_transfer_lock()'active' or 'passive'Locked is active; the setter takes 'enable'/'disable'
get_contacts()[type => contact]Same four types and keys as the write side

The TLD Catalogue Shape

tlds() and cost_prices() share three formats, all normalised to the extended one on import. The extended one carries the year limits and the options the order form may offer.

all three accepted formats
// Format 1, price only. Priced in the module's own cost currency, which is the
// integer currency id under settings['cost-currency'] (default 4, USD).
return [
    'com' => ['register' => 9.90, 'transfer' => 9.90, 'renewal' => 9.90],
];

// Format 1.5, one amount per type with its own currency
return [
    'com' => ['price' => ['register' => ['amount' => 9.90, 'currency' => 'USD']]],
];

// Format 2, recommended: per-year, per-currency, with capabilities
return [
    'com' => [
        'min_years'     => 1,
        'max_years'     => 10,        // the renewal term cap the client area enforces
        'whois_privacy' => true,
        'epp_code'      => true,
        'dns_manage'    => true,
        'paperwork'     => false,

        // The inner key is a currency: an ISO code or the integer id, both resolved.
        // There is no per-TLD currency key; the fallback is settings['cost-currency'].
        'pricing' => [
            'register' => [
                1 => ['USD' => ['cost' => 9.90, 'promo' => 7.90]],
                2 => ['USD' => ['cost' => 19.00]],
            ],
            'renewal'  => [1 => ['USD' => ['cost' => 9.90]]],
            'transfer' => [1 => ['USD' => ['cost' => 9.90]]],
        ],
    ],
];

Example

A registration, then the sync that keeps the local record honest. register() is fire and forget; sync() later proves the domain exists with the provider's dates.

Acme.php, register()
public function register(): array|bool
{
    $this->initApi();

    // Unicode in, punycode out. Every provider wants ASCII.
    $domain = idn_to_ascii($this->options['domain'] ?? $this->service['name'], 0, INTL_IDNA_VARIANT_UTS46);
    $tld    = (string) ($this->options['tld'] ?? '');
    $year   = (int) ($this->options['year'] ?? $this->service['period_time']) ?: 1;
    $whois  = $this->options['whois'] ?? [];
    $dns    = $this->options['dns'] ?? [];

    $params = [
        'domain' => $domain,
        'year'   => $year,
        'dns'    => array_values($dns),
    ];

    // The paid whois-privacy add-on, if this order carried one.
    if ((bool) ($this->addon_params['whois-privacy'] ?? false))
        $params['privacy_protection'] = true;

    // TLD paperwork declared in config settings['doc-fields'][$tld].
    foreach ($this->config['settings']['doc-fields'][$tld] ?? [] as $docId => $doc) {
        if (($doc['required'] ?? false) && strlen((string) ($this->docs[$docId] ?? '')) < 1)
            throw new Exception('The document "' . self::get_doc_lang($doc['name']) . '" is not specified!');

        $value = $this->docs[$docId] ?? '';

        // A file field holds a PATH, not the bytes.
        if (($doc['type'] ?? '') === 'file') $value = base64_encode((string) file_get_contents($value));

        $params['documents'][$docId] = $value;
    }

    // Our four contact types, mapped onto the provider's names.
    $map = ['registrant' => 'owner', 'administrative' => 'admin', 'technical' => 'tech', 'billing' => 'billing'];

    foreach ($map as $ours => $theirs)
        $params['contacts'][$theirs] = [
            'first_name' => $whois[$ours]['FirstName'] ?? '',
            'last_name'  => $whois[$ours]['LastName'] ?? '',
            'company'    => $whois[$ours]['Company'] ?? '',
            'email'      => $whois[$ours]['EMail'] ?? '',
            'address1'   => $whois[$ours]['AddressLine1'] ?? '',
            'city'       => $whois[$ours]['City'] ?? '',
            'state'      => $whois[$ours]['State'] ?? '',
            'zip'        => $whois[$ours]['ZipCode'] ?? '',
            'country'    => $whois[$ours]['Country'] ?? '',
            'phone_cc'   => $whois[$ours]['PhoneCountryCode'] ?? '',
            'phone'      => $whois[$ours]['Phone'] ?? '',
        ];

    $this->api->call('domain/register', $params);

    return true;
}
Acme.php, sync()
public function sync(): array|false
{
    $this->initApi();

    $domain  = idn_to_ascii($this->options['domain'] ?? $this->service['name'], 0, INTL_IDNA_VARIANT_UTS46);
    $details = $this->api->call('domain/info', ['domain' => $domain]);

    if (!$details) return false;        // false means "could not ask", NOT "gone"

    $map = [
        'active'                => 'active',
        'expired'               => 'expired',
        'transferred-elsewhere' => 'transferred',
    ];

    return [
        'creationtime' => DateManager::format('Y-m-d', $details['creation_date'] ?? ''),
        'endtime'      => DateManager::format('Y-m-d', $details['expiration_date'] ?? ''),

        // Anything you cannot map becomes 'unknown'. Never guess 'active':
        // the sync writes the due date, and a wrong guess renews nothing.
        'status'       => $map[strtolower((string) ($details['status'] ?? ''))] ?? 'unknown',
    ];
}

The scheduled status sync calls it, compares the answer with the local service and moves the due date. false and 'unknown' both tell the core to leave the local record alone.

Pitfalls

An XML provider needs its own parser

Utility::xdecode() produces no @attributes entry and mangles repeated elements. A response whose meaning lives in attributes or a repeated list comes back silently wrong; parse XML inside the module.

A submitted transfer is not an owned domain

transfer() returning true only means the request was accepted; the domain becomes active when transfer_sync() says so.

Convert to punycode at every boundary

Nameservers can be internationalised too. The template runs idn_to_ascii() over each entry before saving them.

A helper class next to the module is not autoloaded

The autoloader maps module type directories, not the files inside them; include_once the client from initApi(). A fatal from a missing class inside a hook is swallowed and takes the rest of that hook with it.

Log every call, and test the expensive ones last

Wire the client's logger to save_log() on day one, then work in cost order: connection test, availability check, read-only screens, and only then a real registration.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.