# Writing a Registrar Module

https://dev.wisecp.com/es/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](https://dev.wisecp.com/en/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

```text
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

```php
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.

```php
// 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

```php
// 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

```php
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);
```

```php
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.

```php
$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.

```php
// 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.

```php
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;
}
```

```php
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.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway)
- [Writing a Server Module](https://dev.wisecp.com/en/writing-a-server-module)
- [Error Handling](https://dev.wisecp.com/en/error-handling)
