Writing a Registrar Module
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 fromnew. - Copy
ExampleRegistrarModuleto your own directory and rename the class, the file and the config name together.
Structure
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.
$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
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.
// 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
// 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;
['status' => 'inprocess'].
renew(), as every current module does, or declare renewal() and let the base call it. Not both.
domains() into a local service. Your side is domains() and get_info().
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
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);
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 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
idn_to_ascii() before sending.
name is the second level label and sld its legacy alias, so read $this->options['sld'] ?? $this->options['name'] ?? ''.
$this->service['period_time'], then to 1.
array_values() of it; providers reject a JSON object where they expect an array.
registrant, administrative, technical, billing. Shape below.
transfer() instead of register().
settings['doc-fields'][$tld]. A file field holds a path, so base64 the contents. Labels come from get_doc_lang().
whois-privacy. Read it to decide whether to buy the provider's equivalent.
$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
| Method | Shape | Notes |
|---|---|---|
check() | [tld => ['status' => 'available'|'unavailable']] | Keyed by TLD. A premium name adds premium => true and premium_price => ['amount' => float, 'currency' => string] |
register() | true on success | An array may carry extra service fields; false triggers the failure hook |
transfer() | true on submission | The 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 keys | Status 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.
// 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.
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;
}
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
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.
transfer() returning true only means the request was accepted; the domain becomes active when transfer_sync() says so.
Nameservers can be internationalised too. The template runs idn_to_ascii() over each entry before saving them.
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.
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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.