Create your first registrar
Build a minimal but working domain-registrar extension from scratch — two files, register and renew — then grow it capability by capability.
On this page
A registrar extension is smaller than most people expect. There is no application to scaffold, no views to build, no routes to register. Two files — a driver class and a manifest — and Core does the rest. This walkthrough builds a minimal but real registrar from scratch so you can see the whole shape before you fill in the detail.
We will stub a fictional "Acme Registrar" that speaks a plain JSON API. The endpoints here (/v1/domains, /v1/domains/{name}/renew) are illustrative — swap them for your registrar's real ones — but everything Core touches (the base class, the return shapes, the helpers, the capabilities, the manifest) is exact.
What you'll build#
A registrar that can do two things: register a domain at checkout and renew it. That is enough to be genuinely useful and to see every moving part. Once it works, adding nameservers, transfer, registrar lock, ID protection, contacts and sync is just more methods of the same kind — the driver contract covers them all.
Search never touches your registrar#
Before you write a line, know the one thing that makes registrars different from panels: domain availability search does not use your driver. Core checks availability itself — RDAP first, then WHOIS on port 43, then a DNS probe — so search works on a fresh install with no registrar configured, and one slow or rate-limited registrar API never gates the storefront's search box. Your driver is called only to register or transfer a domain at checkout, and to manage its lifecycle afterwards. There is no checkAvailability method and availability is not a capability — so this quickstart never implements one.
Lay out the package#
A registrar is a folder with two files at its root:
acme-registrar/
salieno.json the manifest
Acme.php the driver classThat is the entire package. No composer.json, no resources/, no assets. If you split logic into helper classes, put them under your namespace alongside the driver — but you never need to.
Extend AbstractRegistrar and declare capabilities#
Start from App\DomainRegistrars\AbstractRegistrar. It already implements every contract method with a safe "not supported" default and pulls in the driver-helper trait, so you override only what you actually build. Crucially, extending the base declares nothing — every control stays hidden until you opt in through capabilities().
<?php
namespace Salieno\Registrar\Acme;
use App\DomainRegistrars\AbstractRegistrar;
use App\DomainRegistrars\RegistrarCapability;
use App\Models\Domain;
use App\Models\DomainRegister;
class Acme extends AbstractRegistrar
{
public function capabilities(): array
{
return [
RegistrarCapability::REGISTER,
RegistrarCapability::RENEW,
];
}
}That is a valid, installable registrar. It just does nothing yet, because register() and renew() still return the base's "not supported" default. capabilities() is the authoritative source at runtime — Core reads it to decide which controls to render. Declare REGISTER and Core wires up provisioning a domain on a paid order plus the admin "Register" action; declare RENEW and Core enables billing-driven renewal and the admin "Renew" button. Omit a capability — transfer, nameservers, registrar lock, ID protection — and Core hides that control entirely, never shown-and-failing. The full map lives in Capabilities.
Implement register()#
register($domain, $nameservers) registers the name and reports whether it worked. Core hands you the Domain and the nameserver list; you never fetch them. Keep to the return shape exactly — Core reads it generically:
public function register(Domain $domain, array $nameservers): array
{
try {
$registrar = $this->registrarOf($domain);
$parts = $this->parseDomain($domain);
$response = $this->http(['Authorization' => 'Bearer ' . $this->getParam($registrar, 'api_key', '')])
->asJson()
->post($this->baseUrl($registrar) . '/v1/domains', [
'sld' => $parts['sld'],
'tld' => $parts['tld'],
'years' => max(1, (int) ($domain->reg_period ?? 1)),
'nameservers' => $nameservers,
]);
$body = $response->json() ?? [];
if (! $response->successful() || ! ($body['registered'] ?? false)) {
return $this->error(
$body['error'] ?? 'Acme did not confirm registration for ' . $domain->domain . '.',
$domain
);
}
// Persist what the registration produced. Core records the outcome from the
// success flag; you write status / nameservers / expiry back onto the domain.
$domain->update(['status' => Domain::STATUS_ACTIVE]);
return $this->success('Domain registered successfully.', [
'data' => ['expiry_date' => $this->normalizeDate($body['expires_at'] ?? null)],
]);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), $domain);
}
}
/** The registrar's API base URL, switched by the connection's sandbox toggle. */
protected function baseUrl(?DomainRegister $registrar): string
{
return $this->isSandbox($registrar)
? 'https://api.sandbox.acme.example'
: 'https://api.acme.example';
}Three habits to notice, because they apply to every method you write:
- Read credentials per call. A driver is stateless — Core instantiates it once with no constructor and passes the
Domainto every method — so pull the connection withregistrarOf($domain)and each field withgetParam($registrar, 'api_key', ''). Never cache them on the instance; the next call may be a different domain on a different connection. - Issue every API call through `http($headers)`. It applies an identifiable User-Agent (some registrar WAFs reject the bare Guzzle UA), sane connect/response timeouts and TLS verification — details that matter the moment a real API sits behind a WAF. Read the sandbox flag with
isSandbox($registrar)and split the name withparseDomain($domain)intosld/tld. - Return through `success($message, $extra)` and `error($message, $domain)`.
error()also raises an admin notification against the domain and logs the real reason, so pass the$domain— that is what turns a failed registration into something an operator sees.
Implement renew()#
renew($domain, $years) renews for exactly the number of years Core passes — never a hard-coded 1. Same shape, same helpers:
public function renew(Domain $domain, int $years): array
{
try {
$registrar = $this->registrarOf($domain);
$response = $this->http(['Authorization' => 'Bearer ' . $this->getParam($registrar, 'api_key', '')])
->asJson()
->post($this->baseUrl($registrar) . '/v1/domains/' . $domain->domain . '/renew', [
'years' => max(1, $years),
]);
if (! $response->successful()) {
return $this->error('Acme rejected the renewal for ' . $domain->domain . '.', $domain);
}
return $this->success('Domain renewed successfully.', [
'data' => ['expiry_date' => $this->normalizeDate($response->json('expires_at'))],
]);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), $domain);
}
}That is the whole driver: a class, two capabilities, two methods. The objects you reached for — $domain->domain, $domain->reg_period, the nameserver list, the stored api_key — are handed to you or read through a helper; you never wire them up yourself. Every method in the contract has this same small shape: take Domain $domain, return ['success' => bool, 'message' => string, ...]. Detail on each one is in the driver contract.
Write salieno.json#
The manifest tells the marketplace and Core how to load and present your registrar, and supplies the fields Core renders into the connection's config form.
{
"schema": "salieno.registrar/1",
"kind": "registrar",
"slug": "acme-registrar",
"name": "Acme Registrar",
"version": "1.0.0",
"namespace": "Salieno\\Registrar\\Acme",
"driver": "Acme",
"entry": "Acme.php",
"requires_core": ">=1.0.0 <2.0.0",
"capabilities": ["register", "renew"],
"credentials": [
{
"key": "api_key",
"label": "API Key",
"type": "password",
"required": true,
"help": "Create an API key in the Acme dashboard under Settings -> API."
}
]
}A few fields carry weight. slug is both what a connection profile stores as its registrar_slug and your marketplace product slug — they must match. driver, namespace and entry must line up with the class above (entry is required and must end in .php). requires_core is the Core version window your registrar supports, not your own version. The capabilities array here is what Core reads to gate the UI without loading your driver, so keep it identical to your capabilities() method. credentials is a list of {key, label, type, required, help} (type is text or password) — Core renders the Domain Registrars config modal straight from it, so adding a registrar needs no core edits. The sandbox toggle is provided by Core; you never add it. Field-by-field detail is in The manifest, and the credentials list has its own guide in Credentials & connection.
There are no views#
Worth stating plainly: you do not build a single screen. Core owns the admin domain page, the client domain manager, the registrar config form and checkout, and renders each one from what your driver returns and declares. Your job ends at correct return shapes. That is why a partial registrar like this one is first-class rather than broken — Core simply shows the two controls you declared and hides the rest.
Ship it: submit, then install#
A registrar folder on a server does nothing on its own — distribution is marketplace-only and signed, and an extension loads only when it is registered from a signed install and the activated licence owns it. To get yours running:
- Submit the two-file package to marketplace.salieno.com and set its price at creation (free, or a one-time paid product). A reviewer approves it, and on approval the marketplace signs the artifact with its key.
- Install from the admin panel under Domain Registrars → Registrar Extensions. That library lists only registrars the licence owns; installing runs a signed resolve → download → verify → register path, checking the download against the pinned marketplace key before any file is written. On install Core auto-provisions an unconfigured connection profile under Domain Registrars for you to fill in the credentials and hit Test connection.
From there every capability you declared lights up automatically — no Core edits, ever. A copied folder will not run: it is unsigned, so the loader rejects it. The full path, plus how updates work, is in Publishing & updates.
Before you submit, exercise the driver against your registrar's sandbox — see Testing your registrar. Then flesh it out method by method against the contract: transfer and transfer status, nameserver delegation and glue hosts, registrar lock, ID protection, contacts and sync. Each one is the same small, self-contained shape you just wrote twice.