The driver contract
Every method your registrar driver implements, with the exact array shape Core reads back — register, transfer, renew, nameservers, contacts, sync and the rest.
On this page
- The interface, the base class, and the trait
- capabilities()
- register()
- transfer()
- renew()
- changeNameservers()
- Child (glue) nameservers
- getNameservers()
- enableIdProtection() and disableIdProtection()
- lockDomain() and unlockDomain()
- setRenewInfo()
- getEppCode()
- getContactInfo() and updateContactInfo()
- getDomainInfo()
- sync()
- getTransferStatus()
- syncPricing()
- testConnection()
- The objects Core passes you
- $domain
- $registrar
- The helper trait
- The success/error envelopes
- Rules to remember
A registrar driver is one PHP class whose methods Core calls, and whose return values Core reads generically to render every domain screen. There are no views to write. The entire relationship between your registrar and Core is this method contract, and Core reads the return shapes exactly, so this page documents each one precisely. Keep to these shapes and your registrar lights up correctly across the admin domain page, the client domain manager, and checkout.
If you are new to registrars, follow the quickstart first. This is the reference you return to.
One difference from panels frames everything below: domain availability search does not use your registrar. Core searches registrar-free (RDAP, then WHOIS, then a DNS probe via App\Services\DomainAvailabilityService), so there is no checkAvailability in this contract and availability is not a capability. Your driver is called only to register or transfer a domain at checkout, and to manage its lifecycle afterwards.
The interface, the base class, and the trait#
Three types define everything you work with.
App\DomainRegistrars\RegistrarInterface is the contract. Your driver must be an instance of it. It declares every method Core may call.
App\DomainRegistrars\AbstractRegistrar is the base class you should extend. It implements the interface with safe "not supported" defaults for every method, pulls in the helper trait, and returns an empty capabilities() so nothing is shown until you opt in. You override only the methods your registrar actually implements. This is the recommended path.
<?php
namespace Salieno\Registrar\Acme;
use App\DomainRegistrars\AbstractRegistrar;
use App\DomainRegistrars\RegistrarCapability;
use App\Models\Domain;
class Acme extends AbstractRegistrar
{
public function capabilities(): array
{
return [
RegistrarCapability::REGISTER, RegistrarCapability::RENEW,
RegistrarCapability::NAMESERVERS, RegistrarCapability::SYNC,
];
}
public function register(Domain $domain, array $nameservers): array { /* ... */ }
// override only what you implement; everything else stays "not supported"
}App\DomainRegistrars\RegistrarModuleTrait is the helper trait, inherited automatically through AbstractRegistrar. It gives you the pre-configured HTTP client, credential readers, domain parser, and the success/error envelopes described at the end of this page. Issue your API calls through these so behaviour stays consistent across registrars.
The driver is stateless: the registry instantiates it once with no constructor and passes the Domain (or, for two methods, the DomainRegister connection) per call. Read your credentials and context per call through the trait helpers ($this->registrarOf($domain), $this->getParam($registrar, 'api_key'), $this->isSandbox($registrar)) — never from instance state.
You can implement RegistrarInterface directly instead of extending AbstractRegistrar, but then you own a correct implementation of every method. Extending the base is simpler and safer.
capabilities()#
public function capabilities(): arrayReturns a subset of RegistrarCapability::ALL. This is the one method you must always override when extending AbstractRegistrar, because the base declares nothing. Every UI surface asks capabilities() before it shows a control, so a capability you omit is a control Core hides, never a button that fails when clicked. Declare only what your registrar truly supports. The full capability-to-UI mapping is in Capabilities.
public function capabilities(): array
{
return [
RegistrarCapability::REGISTER, RegistrarCapability::TRANSFER, RegistrarCapability::RENEW,
RegistrarCapability::NAMESERVERS, RegistrarCapability::CHILD_NAMESERVERS,
RegistrarCapability::REGISTRAR_LOCK, RegistrarCapability::ID_PROTECTION,
RegistrarCapability::AUTO_RENEW, RegistrarCapability::CONTACTS,
RegistrarCapability::SYNC, RegistrarCapability::TRANSFER_STATUS,
// omit EPP_CODE, DNS, PRICE_SYNC -> Core hides those controls
];
}register()#
public function register(Domain $domain, array $nameservers): arrayProvisions a domain at the registrar. Called when a paid order needs a domain registered and from the admin "Register" action. $nameservers is the delegation list Core wants the domain pointed at, as a positional array ['ns1.x', 'ns2.x', ...]. Returns the standard ['success' => bool, 'message' => string] envelope; optionally add data with expiry_date and/or status if the registrar returns them.
Two rules matter here.
Confirm the registration before you write ACTIVE. A 2xx transport response is not confirmation — registrars carry the real outcome in the body. Verify the registrar's own success flag, and only then persist the confirmed state onto the domain.
Don't invent a status. Write only the Domain::STATUS_* constants the model defines. The worked example sets Domain::STATUS_ACTIVE after it reads Registered="true", and persists the nameservers it applied.
public function register(Domain $domain, array $nameservers): array
{
$ns = $this->nameserversString($nameservers);
$res = $this->command($domain, 'namecheap.domains.create', $this->buildRegistrationParams($domain, $ns));
if (! $res['ok']) {
return $this->error($res['message'], $domain);
}
// confirm the registrar's own success attribute before writing ACTIVE
$attrs = $res['response']['DomainCreateResult']['@attributes'] ?? [];
if (strtolower((string) ($attrs['Registered'] ?? 'false')) !== 'true') {
return $this->error('Registration was not confirmed for ' . $domain->domain . '.', $domain);
}
$parts = explode(',', $ns);
$domain->update([
'ns1' => $parts[0] ?? null, 'ns2' => $parts[1] ?? null,
'ns3' => $parts[2] ?? null, 'ns4' => $parts[3] ?? null,
'status' => Domain::STATUS_ACTIVE,
]);
return $this->success('Domain registered successfully.');
}transfer()#
public function transfer(Domain $domain, string $eppCode): arrayInitiates an inbound transfer with the customer's EPP/auth code. Returns success, and may add transfer_id and status_id for the caller and the transfer poller to use later. A transfer is not instant, so mark the domain Domain::STATUS_PENDING rather than active, and let `getTransferStatus()` drive it to completion.
// ...after verifying the registrar accepted the request (Transfer="true")...
$domain->update(['status' => Domain::STATUS_PENDING]);
return $this->success('Domain transfer initiated successfully.', [
'transfer_id' => $attrs['TransferID'] ?? null,
'status_id' => isset($attrs['StatusID']) ? (int) $attrs['StatusID'] : null,
]);The raw $eppCode may need encoding for your API — the worked example base64-prefixes any code containing non-alphanumeric characters (ctype_alnum($eppCode) ? $eppCode : 'base64:' . base64_encode($eppCode)).
renew()#
public function renew(Domain $domain, int $years): arrayRenews the domain for exactly $years. Called by the billing engine on a renewal order and from the admin "Renew" action. Returns success; optionally add data.expiry_date. Clamp the term to at least one year — never renew for zero.
public function renew(Domain $domain, int $years): array
{
$res = $this->command($domain, 'namecheap.domains.renew', [
'DomainName' => $domain->domain,
'Years' => max(1, $years),
]);
if (! $res['ok']) {
return $this->error($res['message'], $domain);
}
return $this->success('Domain renewed successfully.');
}changeNameservers()#
public function changeNameservers(Domain $domain, array $nameservers): arraySets the domain's delegated nameservers, then writes ns1..ns4 back to the domain so Core's stored record matches the registrar. $nameservers arrives as a positional list ['ns1.x', 'ns2.x', ...]; nameserversString() joins it into the comma-separated form most APIs want, dropping blanks. Returns success.
public function changeNameservers(Domain $domain, array $nameservers): array
{
$parsed = $this->parseDomain($domain); // ['sld' => ..., 'tld' => ...]
$ns = $this->nameserversString($nameservers);
$res = $this->command($domain, 'namecheap.domains.dns.setCustom', [
'SLD' => $parsed['sld'], 'TLD' => $parsed['tld'], 'Nameservers' => $ns,
]);
if (! $res['ok']) {
return $this->error($res['message'], $domain);
}
$parts = explode(',', $ns);
$domain->update([
'ns1' => $parts[0] ?? null, 'ns2' => $parts[1] ?? null,
'ns3' => $parts[2] ?? null, 'ns4' => $parts[3] ?? null,
]);
return $this->success('Nameservers updated successfully.');
}Child (glue) nameservers#
public function registerNameserver(Domain $domain, string $hostname, string $ip): array
public function modifyNameserver(Domain $domain, string $hostname, string $oldIp, string $newIp): array
public function deleteNameserver(Domain $domain, string $hostname): arrayThese manage private / glue nameservers — host records under the domain itself (for example ns1.example.com → 1.2.3.4), distinct from delegation. Each returns ['success' => bool, 'message' => string], and all three are gated behind the child_nameservers capability. Use safeHostname($domain, $hostname) to fully-qualify a bare label within the domain before sending it.
public function registerNameserver(Domain $domain, string $hostname, string $ip): array
{
$parsed = $this->parseDomain($domain);
$res = $this->command($domain, 'namecheap.domains.ns.create', [
'SLD' => $parsed['sld'], 'TLD' => $parsed['tld'],
'Nameserver' => $this->safeHostname($domain, $hostname), 'IP' => $ip,
]);
if (! $res['ok']) {
return $this->error($res['message'], $domain);
}
return $this->success("Nameserver {$hostname} registered successfully with IP {$ip}.");
}getNameservers()#
public function getNameservers(Domain $domain): arrayBacks the client "Private Nameservers" card — the read side of the child-nameserver feature. Returns:
['success' => true, 'nameservers' => [['hostname' => 'ns1.example.com', 'ip' => '1.2.3.4'], ...]]The AbstractRegistrar default returns ['success' => true, 'nameservers' => []], an empty list that hides the card, so a registrar without glue-host support inherits the right behaviour by doing nothing. Build the list as an array of ['hostname' => ..., 'ip' => ...] rows:
$normalized[] = [
'hostname' => $attrs['Nameserver'],
'ip' => $attrs['IP'] ?? ($data['IP'] ?? ''),
];
return $this->success('OK', ['nameservers' => $normalized]);enableIdProtection() and disableIdProtection()#
public function enableIdProtection(Domain $domain): array
public function disableIdProtection(Domain $domain): arrayToggle WHOIS/ID privacy at the registrar and set the id_protection flag on the domain so Core's UI reflects the state. Each returns success. Persist the flag only after the registrar confirms the change.
$domain->update(['id_protection' => true]);
return $this->success('ID Protection enabled successfully.');Some registrars need a privacy handle first (Namecheap fetches and caches a WhoisGuard id before enabling), but the contract Core sees is just the envelope plus the persisted id_protection flag.
lockDomain() and unlockDomain()#
public function lockDomain(Domain $domain): array
public function unlockDomain(Domain $domain): arraySet the transfer (registrar) lock and persist is_locked on the domain. Each returns success. The worked example routes both through one private helper so the two entry points can't drift:
public function lockDomain(Domain $domain): array { return $this->setLock($domain, true); }
public function unlockDomain(Domain $domain): array { return $this->setLock($domain, false); }
protected function setLock(Domain $domain, bool $lock): array
{
$res = $this->command($domain, 'namecheap.domains.setRegistrarLock', [
'DomainName' => $domain->domain, 'LockAction' => $lock ? 'LOCK' : 'UNLOCK',
]);
if (! $res['ok']) {
return $this->error($res['message'], $domain);
}
$domain->update(['is_locked' => $lock]);
return $this->success($lock ? 'Domain locked successfully.' : 'Domain unlocked successfully.');
}setRenewInfo()#
public function setRenewInfo(Domain $domain, bool $autoRenew): arrayPersists the auto_renew flag. Returns success. Where the registrar exposes an API toggle, call it and then persist. Where it does not (Namecheap treats auto-renew as a dashboard/billing setting with no public API), persisting locally so Core's billing engine drives renewals is a legitimate, honest implementation — say so in the message rather than faking a registrar call:
public function setRenewInfo(Domain $domain, bool $autoRenew): array
{
$domain->update(['auto_renew' => $autoRenew]);
return $this->success($autoRenew
? 'Auto-renew enabled (managed by billing).'
: 'Auto-renew disabled (managed by billing).');
}getEppCode()#
public function getEppCode(Domain $domain): arrayReturns the EPP/auth code the customer needs to transfer the domain out. When you can retrieve it by API, return it under epp_code (Core also reads auth_code):
return $this->success('OK', ['epp_code' => $code]);Many registrars deliberately do not expose the code by API — they mail it to the registrant after a manual dashboard request. In that case return a success() whose message tells the user exactly how to retrieve it, and do not declare the `epp_code` capability. Core then shows those manual instructions instead of an API "Get EPP" button that can't work:
public function getEppCode(Domain $domain): array
{
return $this->success('For security, Namecheap does not expose the EPP code via API. '
. 'Retrieve it from the dashboard (Domain List > Manage > Sharing & Transfer > '
. 'Transfer Out); it is emailed to the registrant address.');
}getContactInfo() and updateContactInfo()#
public function getContactInfo(Domain $domain): array
public function updateContactInfo(Domain $domain, array $contactData): arraygetContactInfo() returns the domain's WHOIS/registrant contacts under data:
return $this->success('OK', ['data' => $res['response']['DomainContactsResult'] ?? []]);The AbstractRegistrar default returns ['success' => false, 'data' => []], which the contact modal reads as an empty state.
updateContactInfo() receives the edited contacts and returns success. The keys arrive as {Type}{Field}, where {Type} is one of Registrant / Admin / Tech / AuxBilling and {Field} is a contact field — for example RegistrantFirstName, AdminEmailAddress, TechPhone. Map them onto your registrar's parameters, defaulting anything the form left blank so the registrar's required-field validation still passes:
foreach (['Registrant', 'Admin', 'Tech', 'AuxBilling'] as $type) {
$params["{$type}FirstName"] = $contactData["{$type}FirstName"] ?? 'N/A';
$params["{$type}EmailAddress"] = $contactData["{$type}EmailAddress"] ?? '[email protected]';
$params["{$type}Phone"] = $contactData["{$type}Phone"] ?? '+1.0000000000';
// ...LastName, Address1, City, StateProvince, PostalCode, Country the same way...
}getDomainInfo()#
public function getDomainInfo(Domain $domain): arrayReturns the registrar's raw domain payload under data. Core uses it to confirm a registration went through, and sync() typically reads from it. Returns:
return $this->success('OK', ['data' => $res['response']]);The AbstractRegistrar default returns ['success' => false, 'data' => []].
sync()#
public function sync(Domain $domain): arrayPulls current state back from the registrar on the admin "Sync" action and during billing housekeeping. Return only the keys you actually fetched — any of status (a Domain::STATUS_* int), expiry_date, nameservers (a list), and is_locked (bool). Core applies exactly the keys present and ignores the rest, so a stray null you didn't really read would overwrite a good row.
Because success() merges $data at the top level, these come back as top-level keys, not nested under data:
public function sync(Domain $domain): array
{
$info = $this->getDomainInfo($domain);
if (! ($info['success'] ?? false)) {
return $info;
}
$data = $info['data']['DomainGetInfoResult'] ?? [];
return $this->success('OK', [
'status' => $this->normalizeStatus((string) ($data['@attributes']['Status'] ?? 'Unknown')),
'expiry_date' => $this->normalizeDate($data['DomainDetails']['ExpiredDate'] ?? null),
]);
}The AbstractRegistrar default is deliberately falsey (['success' => false, 'data' => []]) precisely so Core never overwrites good data with nulls when a registrar has nothing to sync. Map the registrar's own status strings onto the Domain::STATUS_* ints yourself (the worked example's normalizeStatus() folds ok/active to STATUS_ACTIVE, expired to STATUS_CANCELLED, and so on).
getTransferStatus()#
public function getTransferStatus(Domain $domain): arrayPolls an in-progress inbound transfer. Returns status_id (int) and status_text:
return $this->success("Current Transfer Status: {$description} (ID: {$statusId})", [
'status_id' => $reportedStatusId,
'status_text' => $description,
]);Core's cron reads status_id to decide whether a transfer is done, still pending, or cancelled. Map your registrar's transfer codes to a stable integer and human-readable text. Watch the sign convention the poller uses: Core treats a negative status_id as a cancellation, so if your registrar overloads negative codes for viable, still-in-progress states, remap only those to a non-negative "still pending" id before returning — otherwise a live transfer gets force-cancelled. (The Namecheap driver does exactly this for its -1/-2/-5/-202 in-progress codes while leaving genuine failures negative.)
syncPricing()#
public function syncPricing(): arrayThe one no-arg method. It backs the admin TLD-pricing import and is gated behind the price_sync capability. Return success (with the imported prices) or error. If you don't wire pricing import, don't declare price_sync, and be explicit rather than faking success:
public function syncPricing(): array
{
return $this->error('Pricing sync is not implemented. Configure TLD pricing manually.');
}testConnection()#
public function testConnection(DomainRegister $registrar): arrayThis method is not part of RegistrarInterface — it lives on AbstractRegistrar (default: "not supported"), and you override it for the admin "Test connection" button. It is the only method that takes a DomainRegister instead of a Domain, because there is no domain yet: you are validating a stored connection profile. Do a lightweight, side-effect-free authenticated probe — the cheapest call that still exercises auth — so a pass proves the credentials work and a failure surfaces the real reason. It is not a capability; the button appears for any configured profile. See Testing your registrar.
public function testConnection(DomainRegister $registrar): array
{
$res = $this->request($registrar, 'namecheap.domains.getList', ['PageSize' => 1, 'Page' => 1], 30);
if (! $res['ok']) {
return $this->error($res['message'], null, false); // no domain -> raise no admin notification
}
return $this->success('credentials are valid.');
}Note the error($message, null, false): passing no Domain (and notify: false) means no admin notification is raised, which is right for an operator-initiated probe.
The objects Core passes you#
$domain#
The domain being acted on (App\Models\Domain):
| Field | Meaning |
|---|---|
$domain->domain | the fully-qualified domain being managed |
$domain->user | the customer (firstname, lastname, email, address, city, state, zip, country_code, mobile) |
$domain->reg_period | requested registration / transfer term in years |
$domain->status | Domain::STATUS_ACTIVE / STATUS_PENDING / STATUS_SUSPENDED / STATUS_CANCELLED |
$domain->id_protection | WHOIS-privacy flag you set |
$domain->is_locked | transfer-lock flag you set |
$domain->auto_renew | auto-renew flag you set |
$domain->whois_guard | registrar-specific privacy handle (e.g. Namecheap WhoisGuard id) |
$domain->ns1 .. $domain->ns4 | stored delegated nameservers |
$domain->domainRegister | the connection profile — reach it via registrarOf($domain) |
$domain->update([...]) | persist the changes you make |
$registrar#
The connection profile the operator configured under Domain Registrars (App\Models\DomainRegister) — the encrypted credentials from your manifest's credentials list plus the test_mode sandbox toggle. You never read it directly; the trait helpers do:
| Helper | What it reads |
|---|---|
registrarOf($domain) | resolves the profile from a domain |
getParam($registrar, 'api_key', '') | one credential the operator entered |
isSandbox($registrar) | the profile's test_mode flag |
testConnection() receives the DomainRegister directly, because at that point there is no domain to resolve it from.
The helper trait#
Use these (inherited through AbstractRegistrar) so every registrar behaves consistently. Note that, unlike a panel's HTTP helper, http() takes no server argument — a registrar picks its own base URL from its credentials and the sandbox flag.
http(array $headers = [], int $timeout = 30) — a pre-configured Laravel HTTP client: an identifiable User-Agent (some registrar WAFs reject the bare Guzzle UA), a 15s connect timeout, TLS verification on (registrar APIs use valid certs). Accept/Content-Type are not forced, because registrars mix XML, JSON and query-string APIs — set them yourself (->asForm(), ->acceptJson(), …).
registrarOf($domain) — the DomainRegister connection behind a domain, or null.
params($registrar) — the normalised credentials object for a connection.
getParam($registrar, $key, $default = null) — reads one credential the operator entered, unwrapping the stored {title, value} shape as well as a plain scalar.
isSandbox($registrar) — whether the connection is configured against the registrar's sandbox endpoints (its test_mode).
parseDomain($domainOrString) — splits a name into ['sld' => ..., 'tld' => ...] ("shop.example.co.uk" → sld / tld).
safeHostname($domain, $hostname) — fully-qualifies a nameserver host within the domain when a bare label is passed.
nameserversString($array) — comma-joins a list of nameservers, dropping blanks.
formatPhone($phone, $countryCode) — formats a number as +<dial>.<number> using the country dial code where known.
countryData() — the bundled country reference data (dial codes, names).
normalizeDate($string) — normalises a registrar's date string to the system datetime format, or null on failure.
$apiUrl = $this->isSandbox($registrar)
? 'https://api.sandbox.namecheap.com/xml.response'
: 'https://api.namecheap.com/xml.response';
$response = $this->http([], 60)->asForm()->get($apiUrl, [
'ApiUser' => $this->getParam($registrar, 'api_user', ''),
'ApiKey' => $this->getParam($registrar, 'api_key', ''),
'Command' => 'namecheap.domains.getList',
]);The success/error envelopes#
Two helpers build the return arrays so you never hand-assemble them.
success($message = 'OK', array $data = []) returns ['success' => true, 'message' => $message] merged with `$data` at the top level. That merge is why getContactInfo returns contacts under a top-level data key, getNameservers under nameservers, sync under status/expiry_date, and getTransferStatus under status_id/status_text — you pass them as $data and they land as sibling keys of success and message.
error($message, $domain = null, $notify = true) returns ['success' => false, 'message' => $message] and, when a `Domain` is passed (and $notify is true), raises an admin notification and logs the failure. Pass the domain from lifecycle methods so the operator sees the real cause; pass null (and notify: false) from an operator-initiated probe like testConnection(), where a notification would be noise.
Rules to remember#
- Return the exact shapes above. Core reads them generically; a missing or renamed key silently drops the value.
- The driver is stateless — no constructor, no instance state. Read credentials per call via
registrarOf()/getParam()/isSandbox(). - Every method takes
Domain $domain, exceptsyncPricing()(no-arg) andtestConnection(DomainRegister $registrar). - In
register(), confirm the registrar actually succeeded before writingSTATUS_ACTIVE, and use onlyDomain::STATUS_*— never invent a status. sync()returns only the keys you truly fetched; Core applies exactly those, so a stray null overwrites good data.getEppCode()with no API: return manual-retrieval instructions and omit theepp_codecapability, so Core shows the instructions instead of a dead button.- Pass the
Domaintoerror()when the operator should be notified; passnull(andnotify: false) for a manual probe. - Declare capabilities honestly — a capability you don't declare is a control Core hides, so a partial registrar is first-class, not broken.
- Search is registrar-free: there is no
checkAvailability, and availability is not a capability.
For the worked, full-featured example every snippet here is drawn from, read the bundled Namecheap driver. Next, map the capabilities to the UI in Capabilities, define your connection form in Credentials, and validate a live connection in Testing your registrar.