Skip to content

Testing your registrar

Unit-test your stateless registrar driver against canned API responses: fake HTTP, use the sandbox, and prove capability honesty and fail-closed errors before you submit.

12 min readUpdated Aug 15, 2026
On this page

A registrar driver is code Core runs in production against a live registrar's API — it registers real domains, spends real money at checkout, and manages names customers depend on. You cannot hot-patch it on a running install: the marketplace signs the artifact on approval, and every installed file is hash-checked at load time (see Security & trust model). So the moment to catch a wrong return shape, a missed error branch, or an unhandled exception is before you submit. This article covers how to test a driver in isolation, against canned responses, so you know it honours the contract every way Core will read it.

Why test in isolation#

You cannot test a registrar by dropping its folder into a running Core. The registry refuses to load an extension with no signed, entitled registrar_extensions row registered from a real install — three gates that are a distribution concern, deliberately out of the loop when you exercise the class yourself.

That is good news: your driver is a plain, stateless PHP class. In a test you construct it with new YourRegistrar() and call its methods. There is no constructor and no instance state — everything a method needs is passed in as the Domain (or DomainRegister) argument and read per call through the trait helpers (registrarOf(), getParam(), isSandbox(), http()). Nothing depends on the registry, and no HTTP server has to run. A focused suite around the class covers exactly what production invokes.

Because AbstractRegistrar supplies a safe "not supported" default for every method, you can build the driver — and its tests — one method at a time: implement register(), test it, declare RegistrarCapability::REGISTER; then move to renew(). Every method you have not written yet still returns a well-formed failure. And remember there is no `checkAvailability` to test — Core searches registrar-free (RDAP/WHOIS/DNS), so your suite covers register, transfer, renew, nameservers, lock, privacy, contacts, EPP and sync, never search.

Step 1: a syntax check with php -l#

Before anything clever, make sure every file parses. Lint the driver and any helper classes in your package:

bash
find your-registrar -name '*.php' -print0 | xargs -0 -n1 php -l

php -l only checks that a file parses — it says nothing about behaviour or the static safety scan the reviewer runs later. Treat a clean lint as the entry ticket, not a pass.

Step 2: assert the type and the capabilities#

Two things Core relies on before it ever calls an API method: your driver is the right type, and it declares only capabilities it truly implements — and those must match the manifest, because Core reads the manifest to gate the UI without loading your code.

php
use App\DomainRegistrars\RegistrarCapability;
use App\DomainRegistrars\RegistrarInterface;
use Salieno\Registrar\Namecheap\Namecheap;

public function test_driver_is_a_registrar(): void
{
    $this->assertInstanceOf(RegistrarInterface::class, new Namecheap());
}

public function test_capabilities_match_the_manifest(): void
{
    $manifest = json_decode(file_get_contents(__DIR__.'/../salieno.json'), true);
    $declared = (new Namecheap())->capabilities();

    sort($manifest['capabilities']);
    sort($declared);
    $this->assertSame($manifest['capabilities'], $declared); // one source of truth, two files

    foreach ($declared as $cap) {
        $this->assertContains($cap, RegistrarCapability::ALL); // no typo'd key survives
    }

    // A control you do NOT back must stay hidden — assert it is absent, not shown-and-failing.
    $this->assertNotContains(RegistrarCapability::EPP_CODE, $declared);   // Namecheap emails the code
    $this->assertNotContains(RegistrarCapability::DNS, $declared);        // delegation only, no zone
    $this->assertNotContains(RegistrarCapability::PRICE_SYNC, $declared); // pricing set by hand
}

Extending AbstractRegistrar makes the instanceof check pass for free. The capabilities checks guard a subtler mistake: a typo like 'name_servers' instead of 'nameservers' is silently dropped by Core's RegistrarCapability::sanitize(), so the control never appears. Asserting every declared key is in RegistrarCapability::ALL catches that, and asserting the manifest array equals capabilities() catches the two drifting apart. See The registrar contract for the full capability-to-control map, and pair each capability you declare with a happy-path test of the method behind it.

Step 3: a faked-HTTP contract harness#

The heart of your suite feeds the driver canned responses and asserts each method returns the exact shape Core reads. The trick: the trait's http() helper issues every request through Laravel's Http facade, so Http::fake() intercepts them all — you never touch a real registrar.

Run these inside a Core checkout, extending Core's base test case, so the Http facade and the __() translator are bootstrapped. Build two lightweight doubles — a DomainRegister connection holding the credentials and the sandbox flag, and a Domain bound to it via setRelation(), the only wiring the stateless driver reads:

php
use App\Models\Domain;
use App\Models\DomainRegister;
use Illuminate\Support\Facades\Http;
use Salieno\Registrar\Namecheap\Namecheap;
use Tests\TestCase;

class NamecheapContractTest extends TestCase
{
    /** A configured sandbox connection profile — no database row required. */
    private function connection(bool $sandbox = true): DomainRegister
    {
        $registrar = new DomainRegister();
        $registrar->test_mode = $sandbox;      // -> $this->isSandbox($registrar) is true
        $registrar->params = [
            'api_user' => 'apiuser', 'api_key' => 'test-key', 'username' => 'apiuser',
            'client_ip' => '192.0.2.10',        // a valid IP so resolveClientIp() doesn't fall back
        ];
        return $registrar;
    }

    /** A Domain bound to a connection — the only context the driver reads per call. */
    private function domain(string $name = 'example.com'): Domain
    {
        $domain = new Domain();
        $domain->domain = $name;
        $domain->reg_period = 1;
        $domain->setRelation('domainRegister', $this->connection());
        return $domain;
    }
}

The URL patterns and XML below are Namecheap's, matching the reference driver in registrars/namecheap. Swap them for your registrar's endpoints and payloads — the assertions are what carry over.

The `Http::fake()` append trap. Within one PHP process, repeated Http::fake() calls accumulate stubs, and the first matching stub wins. A broad catch-all '*' shadows every specific pattern you add afterwards. So keep each scenario in its own test method (PHPUnit resets fakes between tests), and if you combine patterns in one map, list the specific URLs before the catch-all — do not stack two conflicting catch-alls and expect the second to apply.

testConnection — the health probe#

The admin "Test connection" button calls testConnection(DomainRegister). It must return success on valid credentials and a clear, verbatim failure otherwise. Namecheap uses namecheap.domains.getList with PageSize=1 — the cheapest call that still exercises auth and the IP whitelist:

php
public function test_connection_succeeds_on_valid_credentials(): void
{
    Http::fake([
        'api.sandbox.namecheap.com/*' => Http::response(
            '<ApiResponse Status="OK"><Errors/><CommandResponse>'.
            '<DomainGetListResult/></CommandResponse></ApiResponse>', 200),
    ]);

    $result = (new Namecheap())->testConnection($this->connection());

    $this->assertTrue($result['success']);

    // test_mode on -> the probe must hit the SANDBOX host, using the cheap getList command.
    Http::assertSent(fn ($request) =>
        str_contains($request->url(), 'api.sandbox.namecheap.com')
        && str_contains($request->url(), 'Command=namecheap.domains.getList')
    );
}

public function test_connection_surfaces_the_real_registrar_error(): void
{
    Http::fake([
        '*' => Http::response(
            '<ApiResponse Status="ERROR"><Errors>'.
            '<Error Number="1011150">Invalid request IP</Error>'.
            '</Errors></ApiResponse>', 200),
    ]);

    $result = (new Namecheap())->testConnection($this->connection());

    $this->assertFalse($result['success']);
    $this->assertStringContainsString('Invalid request IP', $result['message']); // not swallowed
}

The second test pins the most common real failure: the server's outbound IP is not whitelisted, and Namecheap answers Invalid request IP. A good driver surfaces that verbatim so the operator knows exactly what to fix — never a generic "connection failed". Http::assertSent() verifies the request you sent, so the sandbox-host assertion proves your isSandbox() branch actually points at the test endpoint. Exercise the sandbox before you ever point a profile at the live API.

renew() and the lifecycle methods#

renew(), lockDomain(), changeNameservers() and friends take a Domain and return ['success' => bool, 'message' => string]. Renew for exactly the years passed, and assert both the envelope and the command that went out:

php
public function test_renew_sends_the_right_command_and_years(): void
{
    Http::fake([
        '*' => Http::response(
            '<ApiResponse Status="OK"><Errors/><CommandResponse>'.
            '<DomainRenewResult DomainName="example.com" Renew="true"/>'.
            '</CommandResponse></ApiResponse>', 200),
    ]);

    $result = (new Namecheap())->renew($this->domain(), 2);

    $this->assertTrue($result['success']);
    Http::assertSent(fn ($request) =>
        str_contains($request->url(), 'Command=namecheap.domains.renew')
        && $request['DomainName'] === 'example.com'
        && (int) $request['Years'] === 2
    );
}

Registrar APIs differ in where auth travels — Namecheap puts credentials in the query string, others use headers or a POST body — so let Http::assertSent() pin whichever yours uses. Note the exact camelCase in the contract (enableIdProtection, unlockDomain, getEppCode): a test that calls each method is the fastest way to notice a casing slip.

sync() returns the keys Core writes back#

sync() feeds data back into Core, so its shape matters most. Return only the keys you actually have — status (a Domain::STATUS_* int), expiry_date, nameservers, is_locked — never a populated set of guesses. Assert the mapping on a known payload:

php
public function test_sync_maps_status_and_expiry(): void
{
    Http::fake([
        '*' => Http::response(
            '<ApiResponse Status="OK"><Errors/><CommandResponse>'.
            '<DomainGetInfoResult Status="Ok" DomainName="example.com">'.
            '<DomainDetails><ExpiredDate>05/01/2027</ExpiredDate></DomainDetails>'.
            '</DomainGetInfoResult></CommandResponse></ApiResponse>', 200),
    ]);

    $result = (new Namecheap())->sync($this->domain());

    $this->assertTrue($result['success']);
    $this->assertSame(Domain::STATUS_ACTIVE, $result['status']);        // 'Ok' -> STATUS_ACTIVE
    $this->assertSame('2027-05-01 00:00:00', $result['expiry_date']);   // registrar date normalised
}

The base sync() returns ['success' => false, 'data' => []] on purpose — a falsey result so Core does not overwrite good rows with nulls when a registrar has nothing to say. Preserve that discipline: if you cannot read a field, omit it.

register() and the methods that persist#

One caveat: register(), transfer(), lockDomain(), changeNameservers() and the ID-protection toggles both read from and write to the model — register() reads the buyer ($domain->user) and calls $domain->update([...]) on success. Unlike the read-only methods above, these need Core's test database booted and a saved Domain with its user relation, so add the RefreshDatabase trait and attach a user first. Then assert the same two things: the returned envelope (success => true) and the persisted outcome ($domain->fresh()->status === Domain::STATUS_ACTIVE), plus Http::assertSent() on the command that went out. The driver persists only the nameservers and status it now knows — never anything else Core owns.

Error paths must fail closed#

Every public method must return a clean success => false when the registrar says no or the connection breaks — never throw, never return a truthy result. The reference driver wraps each API call in try/catch and returns $this->error(...); prove it across the three real failure modes — missing credentials, an error envelope, and a garbage body:

php
public function test_missing_credentials_fail_with_actionable_guidance(): void
{
    $registrar = $this->connection();
    $registrar->params = ['client_ip' => '192.0.2.10']; // api_user / api_key absent
    $domain = (new Domain()); $domain->domain = 'example.com';
    $domain->setRelation('domainRegister', $registrar);

    $result = (new Namecheap())->renew($domain, 1);

    $this->assertFalse($result['success']);
    $this->assertStringContainsString('credentials', $result['message']); // tells the operator what to fix
    Http::assertNothingSent();                                            // bailed before any call
}

public function test_renew_fails_closed_on_a_non_xml_body(): void
{
    // A WAF or a redirect to an HTML login page is a common real failure.
    Http::fake(['*' => Http::response('<html><body>Access denied</body></html>', 200)]);

    $result = (new Namecheap())->renew($this->domain(), 1);

    $this->assertFalse($result['success']);
    $this->assertIsString($result['message']); // a clear operator message, not a fatal error
}

An empty, non-2xx, or unparseable body must decode to a clean error, not a fatal. Confirm the same for a thrown transport failure (Http::fake a connection exception) — the method should still return success => false, not bubble out. And remember the trait's error() raises its admin notification best-effort inside its own try/catch, so a logging or notification failure can never mask the real registrar error.

Integration: the full signed install#

Unit tests prove the contract; one end-to-end pass proves the plumbing. A copied folder will not run — it is unsigned — so go through the real path on a test install:

  1. Submit the package to marketplace.salieno.com; on approval it is signed (see Publishing your registrar).
  2. In admin, open Domain Registrars → Registrar Extensions and install it. The install verifies the signature against Core's pinned key, registers the driver, and auto-provisions an unconfigured connection profile.
  3. Open that profile, fill in the credentials Core rendered from your manifest's credentials list (see Credentials & the config form), flip on test mode, and click Test connection.
  4. With a green probe, exercise the real operations — register, renew, nameservers, lock, sync — on a domain from the admin domain page, confirming that only the controls you declared appear.

Do the live exercise against the sandbox first; only then a single real registration on the live API.

Keep the static safety scanner happy#

When you submit, the marketplace runs a static safety scan before a human reviews. It flags dynamic code-execution patterns — the shell and eval family. A registrar driver never needs any of them: it makes HTTP calls through http() and parses the response (Namecheap uses the XML and JSON builtins, all fine). If the scanner finds a flagged token, expect a rejection.

The gotcha is false positives. The scan matches token patterns, not reachability — a flagged name immediately followed by an open paren can trip it even inside a comment or a string literal. Keep those tokens out of comments and strings; describe them in words rather than writing the name-and-paren. php -l will happily parse code the scanner rejects, so pass both. More on what the reviewer checks is in Security & trust model.

Pre-submit checklist#

Before you package and submit:

  • php -l is clean on every .php file in the package.
  • The driver is instanceof RegistrarInterface, capabilities() returns only keys in RegistrarCapability::ALL, and it equals the manifest's capabilities array.
  • A capability you do not implement is absent from capabilities() and its control stays hidden.
  • Every declared capability has a happy-path test proving its method returns the right envelope.
  • testConnection() returns success on valid sandbox credentials and surfaces the real error (e.g. Invalid request IP) otherwise.
  • sync() returns only the keys it has, and is falsey when there is nothing to report.
  • Every method fails closed — success => false, no throw — on missing credentials, an error envelope, a non-XML/JSON body, and a connection failure.
  • The sandbox branch (isSandbox()) is proven to hit the test host with Http::assertSent.
  • No shell/eval token appears anywhere in the package, comments and strings included.

With those green, your driver behaves the same in every surface Core renders from it. The remaining steps — signing, entitlement, install — are the marketplace's job, covered in Publishing your registrar.

Was this article helpful?
Still stuck?Contact support
Testing your registrar · Salieno Docs