Capabilities and the UI
A registrar's declared capabilities decide which controls Core draws — at checkout, in the client domain manager and admin page. Declare one to show it; omit it to hide it.
On this page
A registrar extension never renders a screen of its own. Core owns every surface a domain touches — the checkout domain step, the admin domain operations page, the client domain manager, the Domain Registrars connection form — and decides what to draw from one thing your driver declares: its capabilities. Declare a capability and Core shows the control. Omit it and Core hides it. There is no third state where a button appears and then fails when clicked. Core never enumerates registrar names; every surface asks whether the registrar backing this domain supports the action, and draws accordingly.
This article covers the RegistrarCapability vocabulary, exactly what each capability lights up across the three domain surfaces, and the "honest capabilities" principle that makes a partial registrar first-class: Core asks whether you support an action before it ever shows the control.
The capability vocabulary#
Capabilities are string constants on App\DomainRegistrars\RegistrarCapability. There are fourteen, grouped by what they govern:
namespace App\DomainRegistrars;
final class RegistrarCapability
{
// Provision / transact
public const REGISTER = 'register'; // register a new domain at checkout
public const TRANSFER = 'transfer'; // inbound transfer (needs an EPP/auth code)
public const RENEW = 'renew'; // renew for N years
// Delegation + naming
public const NAMESERVERS = 'nameservers'; // change delegated nameservers
public const CHILD_NAMESERVERS = 'child_nameservers'; // private (glue) nameservers
public const DNS = 'dns'; // registrar-hosted DNS zone records
// Transfer + security controls
public const EPP_CODE = 'epp_code'; // retrieve the EPP/auth code via API
public const REGISTRAR_LOCK = 'registrar_lock';// get/set the transfer lock
public const ID_PROTECTION = 'id_protection'; // enable/disable WHOIS/ID privacy
public const AUTO_RENEW = 'auto_renew'; // toggle registrar-side auto-renew
// Records + introspection
public const CONTACTS = 'contacts'; // get/update registrant/admin/tech contacts
public const SYNC = 'sync'; // pull status + expiry + nameservers back
public const TRANSFER_STATUS = 'transfer_status'; // poll an in-progress transfer
// Admin-only
public const PRICE_SYNC = 'price_sync'; // import TLD pricing from the registrar
public const ALL = [ /* all fourteen, in the order above */ ];
}RegistrarCapability::ALL is the full set — the sensible declaration for a registrar that genuinely does everything. Your capabilities() method returns some subset of it.
What is not a capability: availability search#
Before the table, the one thing that makes a registrar different from a panel. Domain availability search is deliberately not a capability. Core checks availability itself — RDAP first, then WHOIS on port 43, then a DNS probe — through App\Services\DomainAvailabilityService. There is no checkAvailability in the contract and nothing in RegistrarCapability for it. So search works on a fresh install with no registrar configured, and one slow or rate-limited registrar API can never gate the storefront's search box. Your driver is consulted only to register or transfer a domain at checkout, and to manage its lifecycle afterwards — everything below is one of those two jobs.
What each capability lights up#
Each capability maps to one or more concrete controls. Declare only the capabilities behind which you have a working method, because Core will surface every control in this table the moment you list its capability.
| Capability | Constant | What it lights up in Core |
|---|---|---|
register | REGISTER | Provisioning a domain on a paid order, plus the admin Register action |
transfer | TRANSFER | Inbound transfer at checkout, plus the admin Transfer action (needs an EPP/auth code) |
renew | RENEW | Billing-driven renewal, plus the admin Renew action |
nameservers | NAMESERVERS | Client and admin Manage Nameservers (delegation; changeNameservers) |
child_nameservers | CHILD_NAMESERVERS | The client Private Nameservers card (glue hosts; registerNameserver / modifyNameserver / deleteNameserver / getNameservers) |
dns | DNS | Registrar-hosted DNS zone-record management |
epp_code | EPP_CODE | The admin Get EPP button (fetch via API). Omit it and the client EPP control self-adapts — see below |
registrar_lock | REGISTRAR_LOCK | Client and admin transfer-lock toggle (lockDomain / unlockDomain) |
id_protection | ID_PROTECTION | Client and admin WHOIS/ID-privacy toggle (enableIdProtection / disableIdProtection) |
auto_renew | AUTO_RENEW | Client and admin auto-renew toggle (setRenewInfo) |
contacts | CONTACTS | Client Contact Information and admin Contacts editor (getContactInfo / updateContactInfo) |
sync | SYNC | Admin Sync — pull status, expiry and nameservers back (sync) |
transfer_status | TRANSFER_STATUS | Admin Transfer Status polling (getTransferStatus) |
price_sync | PRICE_SYNC | The admin TLD-pricing import (syncPricing) |
The same declarations drive three surfaces. At checkout, register and transfer decide whether a domain can be bought at all through this registrar — one provisions a paid domain order, the other runs the inbound transfer and collects the customer's EPP/auth code. In the client domain manager, the customer sees only the self-service controls their registrar backs: delegation, the private-nameserver card, the lock/privacy/auto-renew toggles and the contact editor. On the admin domain operations page, an operator additionally gets the reconciliation tools — Sync, Transfer Status and TLD-pricing import. Anything you do not declare is simply not on any of them.
One control is worth calling out as the standout registrar-specific behaviour. The client EPP-retrieval control is always visible; only the admin API button is gated. Declare epp_code and the admin gets a Get EPP button that calls your API. Omit it — as a registrar that emails the auth code to the registrant must — and the client EPP control does not disappear: it self-adapts, showing manual retrieval instructions instead of an API fetch. The customer always has a way to start a transfer out; the capability only decides whether that way is an API call or a set of instructions.
Core asks before it shows#
Every domain surface routes its decision through one seam, RegistrarResolver::supports():
public function supports(Domain $domain, string $capability): bool
{
return in_array($capability, $this->capabilitiesFor($domain), true);
}The interesting part is capabilitiesFor(). It does not load your driver to answer — it reads the capability list off the installed extension's signed manifest (a cheap, signature-checked lookup) and runs it through RegistrarCapability::sanitize().
Two properties follow. First, because gating reads the manifest and never executes driver code, a bug in your driver can never break a control's visibility — the UI has already decided what to draw before any of your methods run. Second, a domain whose configured registrar profile has no matching installed, entitled extension resolves to NullRegistrar, whose capabilities() is []. Either way an unresolved registrar declares nothing, so the domain degrades to a read-only view rather than throwing.
The payoff is the design rule that runs through the whole registrar system: an undeclared action is hidden, never shown-and-failing. Customers and operators only ever see controls that lead to a real registrar API call.
Declaring capabilities in your driver#
There are two ways a driver ends up with a capability set, and they have opposite defaults. Pick deliberately.
Extend `AbstractRegistrar` (recommended). Its capabilities() returns an empty array by default, so nothing is declared until you opt in. You override the method to list exactly what you implement:
use App\DomainRegistrars\AbstractRegistrar;
use App\DomainRegistrars\RegistrarCapability;
class MyRegistrar extends AbstractRegistrar
{
public function capabilities(): array
{
return [
RegistrarCapability::REGISTER,
RegistrarCapability::RENEW,
RegistrarCapability::NAMESERVERS,
RegistrarCapability::SYNC,
];
}
// ...override only the methods for the capabilities above
}This is the safe path for any registrar that does not do everything: because the default hides all controls, a half-built registrar is safe rather than broken — a control can only appear once you have both declared its capability and written its method.
Implement `RegistrarInterface` directly. If you pull in RegistrarModuleTrait yourself instead of extending AbstractRegistrar, the trait's default capabilities() returns RegistrarCapability::ALL — the right choice only for a registrar that genuinely implements every capability. AbstractRegistrar starts you at nothing and makes you add; the trait starts you at everything and makes you subtract. A partial registrar should extend AbstractRegistrar so its default is honest.
Whichever path you choose, the manifest's `capabilities` array must match your driver's `capabilities()` exactly. Core reads the manifest to gate the UI (see above) and trusts the driver at runtime; if they disagree, a control can appear that the driver refuses, or vice versa. One safety net applies to both paths and both sources: RegistrarCapability::sanitize() intersects whatever you declare with the recognised set, so a typo like 'auto_renwe' is silently dropped rather than lighting up a control. A misspelled capability is a missing capability, never an error.
Honest capabilities: the Namecheap example#
The worked example in the repo is deliberately not a full-house declaration. Namecheap declares eleven of the fourteen capabilities — and that is the correct, first-class answer, not a shortcut.
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
];
}It omits three, and each omission is principled:
- `epp_code` — Namecheap has no API command to retrieve the auth code; for security it is emailed to the registrant after a manual dashboard request. Declaring
epp_codewould put an admin Get EPP button in front of an operation that can only fail. By omitting it, Core hides that admin button and the client EPP control self-adapts to manual instructions, which is the truth. - `dns` — this driver does nameserver delegation and child (glue) hosts, not registrar-hosted DNS zone records. Declaring
dnswould surface a zone-record editor with no backing call. - `price_sync` — TLD-pricing import is not wired; operators set Core's pricing themselves. Declaring it would offer an admin import that does nothing.
The shape holds for any registrar: one with a registrar-hosted DNS API, an EPP-retrieval endpoint and a pricing feed would earn all fourteen. The failure mode to avoid is the opposite — declaring a capability whose semantics do not fit your registrar, in the hope of looking complete. That produces exactly the shown-and-failing button the whole system exists to prevent.
A partial registrar is first-class#
The capability system rewards honesty. There is no penalty for a smaller declaration: a registrar that only registers, renews and syncs — and nothing else — is a completely valid, shippable extension, and no core edits are ever needed to onboard it. Core simply renders a leaner domain page, with only the controls it can honour.
Declare what you actually implement, back each declared capability with the method behind it, keep the manifest and the driver in step, and let Core adapt every surface around your honest answer. Next, wire those declarations to real methods — the method contract specifies the exact return shape for each one; the overview shows how a registrar plugs into Core; and the quickstart walks a driver from empty class to installed extension.