← All posts

How to look up a domain's expiry date with RDAP and WHOIS (PHP)

Getting a new tool set up can feel like work in itself. You enter the same details you’ve entered somewhere else, make a few decisions, and try to remember where everything lives. A good tool should save you some of that effort, not add to it. That’s part of what I’m trying to do with Yven: make it easier to keep track of client websites without making setup a project of its own.

The onboarding wizard is one example. To add a client, you only need their contact name. Site and service details can wait until later, and if your client list is already in a spreadsheet, you can import a CSV instead of typing it all in again. Enter a site URL and Yven pre-fills a Domain service with that address. One less thing to copy and paste.

There’s another little feature I’m rather pleased with: looking up a domain’s expiry date. If you look after domains for clients, you need to know when they expire. Usually, that means finding the registrar, logging in, and digging around for the date. In Yven, there’s a Look up button. Click it and, if the registry publishes the information, Yven fills in the expiry date and the registrar’s name in the Provider field.

Nothing earth-shattering. Just one less login and one less detail to copy over, potentially repeated across quite a few client sites. Here’s how it works. The general approach should translate to other languages and frameworks. This is code from the actual implementation, with a few sections shortened for readability.

Two ways to look up a domain

The two protocols to know about are WHOIS and RDAP.

WHOIS has been around for a long time. You connect to port 43 over TCP, send the domain name, and get back plain text. The awkward part is that there’s no standard response format. One registry might call the field one thing, while another uses a different label for the same date.

RDAP (Registration Data Access Protocol) is the newer option. It uses HTTPS and returns structured JSON, which is much easier to parse consistently than a pile of registry-specific text formats. Its response structure is defined in RFC 9083.

ICANN requires generic top-level domain (gTLD) registries and registrars to support RDAP. Since January 28, 2025, they have generally no longer been required to provide WHOIS, with some exceptions, including .com. Country-code domains such as .ca fall outside those particular gTLD requirements, which is one reason I’ve kept WHOIS available as a fallback.

You can read more in ICANN’s RDAP FAQ.

Yven tries RDAP first and falls back to WHOIS if needed. If neither returns an expiry date, you can enter it yourself.

That fallback matters. This feature is meant to save time, not block you when a registry doesn’t cooperate.

Start by cleaning up the input

People rarely think about giving a tool a perfectly tidy domain name. They paste in a URL, or include a port, a path, a query string, or a trailing dot.

For example, all of these contain information that needs to be dealt with before making a lookup:

  • https://www.Acme.com/pricing?ref=x

  • acme.com:8080

  • acme.com.

Before making an external request, I normalize the input and validate what’s left. The aim is to handle the sort of things people actually paste into a field, while making sure an invalid domain doesn’t get any further.

Here’s the DomainName class:

final class DomainName
{
    public static function fromInput(string $input): ?string
    {
        $host = trim($input);

        if ($host === '') {
            return null;
        }

        // Leading and trailing whitespace is gone, so any control character left is
        // interior ("acme.com\n/x"). Reject it rather than carve a host out of it.
        if (preg_match('/[\x00-\x1F\x7F]/', $host) === 1) {
            return null;
        }

        // Drop scheme, userinfo, path/query/fragment, and port.
        $host = (string) preg_replace('#^[a-z][a-z0-9+.\-]*://#i', '', $host);
        $host = ltrim($host, '/');

        if (($at = strpos($host, '@')) !== false) {
            $host = substr($host, $at + 1);
        }

        $host = (string) preg_split('~[/?#]~', $host, 2)[0];

        if (($colon = strpos($host, ':')) !== false) {
            $host = substr($host, 0, $colon);
        }

        $host = rtrim(strtolower($host), '.');

        if (str_starts_with($host, 'www.')) {
            $host = substr($host, 4);
        }

        // At least two labels, letters/digits/hyphens only.
        if (! preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/D', $host)) {
            return null;
        }

        return $host;
    }
}

The result is a normalized domain name made up of letters, digits, hyphens, and dots. Paths, ports, and stray line breaks are stripped out or rejected rather than being passed along as part of the domain.

Control characters get rejected up front. If there’s a newline in the middle of the input, I don’t try to rescue a domain from the surrounding text; I reject the whole value. This string will end up in a cache key, a URL, and a WHOIS query. A line break doesn’t belong in any of those.

One small detail in the validation expression is the D modifier. In PHP, $ can match just before a trailing newline, not only at the actual end of the string. D makes the end-of-string check explicit.

I leave subdomains alone, too. If someone enters mail.acme.com, that’s what gets looked up. Reducing it to acme.com correctly means using something like the Public Suffix List; guessing where the registrable domain begins is asking for mistakes.

Ask RDAP first

RDAP returns JSON. The expiry date lives in the events array, in the entry where eventAction is expiration.

Here’s a simplified example of the response for acme.com:

{
  "objectClassName": "domain",
  "ldhName": "ACME.COM",
  "events": [
    {
      "eventAction": "registration",
      "eventDate": "1995-05-05T04:00:00Z"
    },
    {
      "eventAction": "expiration",
      "eventDate": "2027-05-04T04:00:00Z"
    },
    {
      "eventAction": "last changed",
      "eventDate": "2024-04-03T09:12:30Z"
    }
  ],
  "entities": [
    {
      "objectClassName": "entity",
      "handle": "292",
      "roles": ["registrar"],
      "vcardArray": [
        "vcard",
        [
          ["version", {}, "text", "4.0"],
          ["fn", {}, "text", "Example Registrar, Inc."]
        ]
      ]
    }
  ]
}

The response can also include the registrar’s name. More on that in a moment.

Yven sends the request to a public RDAP redirector, which points it to the authoritative server for that top-level domain. The request times out after five seconds by default. There’s no reason to leave someone staring at a spinner because a registry is slow to respond.

The method that makes the request looks like this:

private function fetchRdap(string $domain): array
{
    try {
        $response = Http::acceptJson()
            ->timeout($this->timeout())
            ->get($this->rdapBase().'/domain/'.$domain);
    } catch (Throwable) {
        return ['expiry' => null, 'reached' => false, 'registrar' => null];
    }

    if (! $response->successful()) {
        return ['expiry' => null, 'reached' => false, 'registrar' => null];
    }

    $body = (array) $response->json();

    return [
        'expiry' => RdapExpiryParser::parse($body),
        'reached' => true,
        'registrar' => RegistrarParser::fromRdap($body),
    ];
}

The production request also limits the number of redirects and checks their destinations; I’ve omitted that detail here. If anything goes wrong, including a redirect being refused, the lookup treats it as a failed request.

The method returns the expiry date if it found one, whether the service responded successfully, and the registrar’s name if it was included.

That distinction is useful. A registry can respond perfectly well without publishing an expiry date; that’s not the same as being unable to reach it. The UI needs to be able to explain the difference.

I’ve kept parsing separate from the network request. The parser gets an array of decoded JSON and returns a date or null; it doesn’t need to know how the data was fetched.

It also makes the parser easier to test on its own.

public static function parse(array $rdap): ?CarbonImmutable
{
    $events = $rdap['events'] ?? null;

    if (! is_array($events)) {
        return null;
    }

    foreach ($events as $event) {
        if (! is_array($event) || ($event['eventAction'] ?? null) !== 'expiration') {
            continue;
        }

        $date = $event['eventDate'] ?? null;

        if (! is_string($date) || trim($date) === '') {
            return null;
        }

        try {
            return CarbonImmutable::parse($date);
        } catch (Throwable) {
            return null;
        }
    }

    return null;
}

The parser is deliberately simple: find the expiration event, try to parse its date, and return null if the expected information isn’t there. The calling code decides what happens next.

Fall back to WHOIS

RDAP doesn’t cover every case, so WHOIS is still there as a fallback.

The client makes two requests. First it asks IANA’s WHOIS server which server handles the top-level domain, then it queries that server for the domain itself. Socket errors and timeouts count as lookup failures; they shouldn’t crash the process.

Parsing WHOIS is messier than parsing RDAP. There’s no shared schema, so I look for the labels registries commonly use for expiry dates.

Here are the labels the parser currently recognizes:

private const LABELS = [
    'registry expiry date',
    'registrar registration expiration date',
    'expiration date',
    'expiration time',
    'expiry date',
    'expires on',
    'expire date',
    'domain expiration date',
    'paid-till',
    'renewal date',
    'expires',
];

The parser checks the response line by line. When it finds a label it recognizes, it takes the value after the colon and tries to parse it as a date.

public static function parse(string $raw): ?CarbonImmutable
{
    foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
        $colon = strpos($line, ':');

        if ($colon === false) {
            continue;
        }

        $label = strtolower(trim(substr($line, 0, $colon)));

        if (! in_array($label, self::LABELS, true)) {
            continue;
        }

        $value = trim(substr($line, $colon + 1));

        if ($value === '') {
            continue;
        }

        // .ru uses dotted dates: 2026.09.01 -> 2026-09-01
        $value = (string) preg_replace(
            '/^(\d{4})\.(\d{2})\.(\d{2})/',
            '$1-$2-$3',
            $value
        );

        try {
            return CarbonImmutable::parse($value);
        } catch (Throwable) {
            continue; // a later line may carry a parseable date
        }
    }

    return null;
}

The date normalization is a good example of the little surprises you run into with real-world integrations. A general-purpose date parser won’t understand every format the same way. The .ru response I encountered used dots between the date components, so I convert them before trying to parse the date.

This list covers the labels I’ve encountered and accounted for so far; it won’t cover every registry. If you’ve come across another label, I’d be glad to hear about it.

Name the registrar too

The expiry date is one useful detail from a registrar’s dashboard. The other is the registrar itself. Yven stores that in the service’s Provider field, so the same lookup can fill in both.

Both protocols can provide the name, but they put it in different places. In RDAP, I look for an entity with registrar in its roles and take the display name from the fn property of its jCard (a JSON contact card). In WHOIS, it’s usually on a line beginning Registrar:.

Some RDAP servers nest entities, so the parser walks through them recursively instead of checking only the top level:

public static function fromRdap(array $rdap): ?string
{
    return self::fromRdapEntities($rdap['entities'] ?? null);
}

public static function fromWhois(string $raw): ?string
{
    if (preg_match('/^[ \t]*Registrar:[ \t]*(.+)$/im', $raw, $m) !== 1) {
        return null;
    }

    return self::tidy($m[1]);
}

private static function fromRdapEntities(mixed $entities): ?string
{
    if (! is_array($entities)) {
        return null;
    }

    foreach ($entities as $entity) {
        if (! is_array($entity)) {
            continue;
        }

        $roles = $entity['roles'] ?? [];

        if (is_array($roles) && in_array('registrar', $roles, true)) {
            $name = self::tidy(self::vcardFullName($entity['vcardArray'] ?? null));

            if ($name !== null) {
                return $name;
            }
        }

        $nested = self::fromRdapEntities($entity['entities'] ?? null);

        if ($nested !== null) {
            return $nested;
        }
    }

    return null;
}

This name comes from a remote server, so I don’t take it at face value. Before storing it, I replace control characters with spaces, collapse repeated whitespace, and reject names longer than 120 characters. Some registries return placeholders such as “REDACTED” or “Not Available”; those become null, leaving the form field empty instead of filling it with junk.

There’s one subjective choice here: I strip the legal suffix. “GoDaddy.com, LLC” becomes “GoDaddy.com”. The Provider field is for the name you’d actually use in conversation, not the full corporate name.

private static function tidy(?string $name): ?string
{
    if ($name === null) {
        return null;
    }

    $name = (string) preg_replace('/[\x00-\x1F\x7F]/', ' ', $name);
    $name = trim((string) preg_replace('/\s+/', ' ', $name));

    // ", LLC" / " Inc." / " Ltd." and friends: the legal form, not the name people use.
    $name = trim((string) preg_replace(
        '/[\s,]+(?:L\.?L\.?C\.?|Inc\.?|Incorporated|Ltd\.?|Limited|Corp\.?|Corporation|Co\.?|GmbH|S\.?A\.?|B\.?V\.?|Pty\.? Ltd\.?)$/i',
        '',
        $name,
    ), " \t,");

    if ($name === '' || strlen($name) > self::MAX_LENGTH) {
        return null;
    }

    // Registries sometimes redact or stub the field.
    if (preg_match('/^(?:redacted|not (?:available|applicable|disclosed)|n\/a|none|-+)\b/i', $name) === 1) {
        return null;
    }

    return $name;
}

On a successful lookup, Yven fills in Provider only when the field is empty. If you’ve already entered a value, it leaves it alone:

// Name the registrar as the provider, but never over something already typed.
if ($result->registrar !== null && trim($this->service_provider) === '') {
    $this->service_provider = $result->registrar;
    $this->domainLookupMessage = 'Renewal date and provider ('.$result->registrar.') prefilled from '.$result->source.'.';
}

Return a result, not a guess

After trying the available sources, the application needs a clear account of what happened.

Instead of returning only a date or a vague failure, I use an enum to distinguish the possible outcomes:

enum DomainExpiryStatus: string
{
    case Found = 'found';
    case NotFound = 'not_found';
    case Unsupported = 'unsupported';
    case Unavailable = 'unavailable';
    case InvalidInput = 'invalid_input';
}

Those five statuses cover the cases the application has to deal with:

  • Found: an expiry date was retrieved from RDAP or WHOIS.

  • NotFound: a source responded, but no usable expiry date was found.

  • Unsupported: neither lookup method provided a usable source.

  • Unavailable: a network error, timeout, or rate limit prevented the lookup from completing.

  • InvalidInput: the supplied value wasn’t a valid domain name.

Only Found includes a date, and it may include the registrar’s name as well. For every other status, the user can enter the date manually.

That’s intentional. The lookup should make the job easier, not stop someone from finishing it when an external service can’t help.

The main lookup method ties the pieces together: validate the input, check the cache, make the requests if needed, and cache the result:

public function lookup(string $input): DomainExpiryResult
{
    $domain = DomainName::fromInput($input);

    if ($domain === null) {
        return DomainExpiryResult::invalidInput(
            $input,
            'Enter a domain like acme.com.'
        );
    }

    $key = 'domain-expiry:v2:'.$domain;
    $cached = Cache::get($key);

    if ($cached instanceof DomainExpiryResult) {
        return $cached;
    }

    $result = $this->resolve($domain);

    // Cache stable answers only. A timeout shouldn't stick around for a day.
    if ($result->status !== DomainExpiryStatus::Unavailable) {
        Cache::put($key, $result, now()->addHours(24));
    }

    return $result;
}

The cache has one important rule: successful results and stable answers (such as a response with no published expiry date) can stick around for 24 hours. A temporary network failure shouldn’t. The next request might work just fine.

The v2 in the cache key is there because I added the registrar to the result. Cached results are objects with a read-only registrar property; older cached objects don’t have that property initialized, and trying to read it throws an error. Bumping the key means the application simply won’t pick up those old entries.

The resolver tries RDAP first, then WHOIS if it needs to:

private function resolve(string $domain): DomainExpiryResult
{
    $rdap = $this->fetchRdap($domain);

    if ($rdap['expiry'] !== null) {
        return DomainExpiryResult::found(
            $domain,
            $rdap['expiry'],
            'rdap',
            $rdap['registrar']
        );
    }

    $raw = $this->safeWhois($domain);

    if ($raw !== null) {
        $expiry = WhoisExpiryParser::parse($raw);

        return $expiry !== null
            ? DomainExpiryResult::found(
                $domain,
                $expiry,
                'whois',
                RegistrarParser::fromWhois($raw)
            )
            : DomainExpiryResult::notFound(
                $domain,
                'No expiry field in the WHOIS record.'
            );
    }

    return $rdap['reached']
        ? DomainExpiryResult::notFound(
            $domain,
            'No expiry date is published for this domain.'
        )
        : DomainExpiryResult::unsupported(
            $domain,
            'No RDAP or WHOIS source answered for this domain.'
        );
}

The useful part isn’t just the order of the requests. The result records what actually happened, so the UI doesn’t have to guess why the lookup failed.

I’ve also left the outbound-call limit out of these examples. External services are dependencies, not something an application should hammer without limits.

Test it without a network

I don’t want these unit tests relying on live registries. The tests would become unpredictable, and a third-party service would be able to break a test suite that should be under our control.

The WHOIS client sits behind an interface, so I can swap in a fake for tests. For RDAP, Laravel gives me Http::fake().

This test checks that a successful RDAP lookup returns the expected result and doesn’t fall through to WHOIS:

public function test_rdap_hit_returns_found_and_never_calls_whois(): void
{
    Http::fake([
        'rdap.org/*' => Http::response($this->rdapFixture()),
    ]);

    $this->fakeWhois(
        'SHOULD NOT BE USED Registry Expiry Date: 2099-01-01'
    );

    $result = $this->lookup()->lookup(
        'https://www.acme.com/pricing'
    );

    $this->assertSame(DomainExpiryStatus::Found, $result->status);
    $this->assertSame('rdap', $result->source);
    $this->assertSame(
        '2027-05-04',
        $result->expiresAt?->toDateString()
    );
}

The test uses a saved RDAP response with a known expiry date. When RDAP has the answer, the lookup should return it and never touch WHOIS.

I test the registrar separately as well. This one checks that the name comes through when the answer comes from WHOIS:

public function test_a_whois_hit_carries_the_registrar_name_too(): void
{
    Http::fake([
        'rdap.org/*' => Http::response('', 404),
    ]);

    $this->fakeWhois(
        (string) file_get_contents(
            base_path('tests/Fixtures/domain/whois-acme.txt')
        )
    );

    $result = $this->lookup()->lookup('acme.com');

    $this->assertSame('whois', $result->source);
    $this->assertSame('Example Registrar', $result->registrar);
}

For name cleaning, I’ve got a table of edge cases:

yield 'LLC with comma' => ['GoDaddy.com, LLC', 'GoDaddy.com'];
yield 'Ltd' => ['Gandi Ltd', 'Gandi'];
yield 'suffix-like word inside is kept' => ['Incognito Domains', 'Incognito Domains'];
yield 'control characters removed' => ["Evil\x00Registrar", 'Evil Registrar'];
yield 'redacted' => ['REDACTED FOR PRIVACY', null];

The parsers also have separate tests using saved RDAP and WHOIS responses, including different expiry-date labels. Because they don’t depend on the network, I can test the parsing without worrying about whether a registry happens to be available.

Each test can focus on one thing: given this response, return this date — or null if the required information is missing.

What it doesn’t do

There are a few limits to this implementation. It’s worth spelling them out rather than pretending the lookup covers every possible case.

First, validation currently accepts ASCII domain names only. Internationalized domain names aren’t supported yet, so they’ll need to be handled manually.

Second, it doesn’t reduce subdomains to their registrable domain. Enter mail.acme.com and that’s the hostname it looks up; it won’t assume you meant acme.com.

Third, some registries don’t publish an expiry date. When there’s no date to retrieve, Yven leaves room for manual entry rather than making one up. The date is whatever the registry publishes; it doesn’t tell you whether the registrar will renew the domain automatically. The registrar name isn’t always available either. Some registries omit or redact it, leaving Provider empty. And because I remove legal suffixes, the field shows a familiar name like “GoDaddy.com”, not the full legal entity name.

Finally, the tests use saved responses and fakes rather than live registry queries. That makes them reliable, but they won’t catch a registry changing its response format on their own. If one does change, I may need to update the parser before Yven can find the date again.

That’s another reason to keep manual entry available. Automation should make the usual case quicker without making the awkward cases harder.

A neat trick, built to remove a little friction

It’s a neat trick, not a revolution. But it means one less thing to look up, one less login to hunt down, and one less detail to copy from one place to another.

That’s the sort of work I want Yven to take off your plate: the small, fiddly jobs that get in the way of the work you’d rather be doing.

If you’ve built something similar, or know a registry that uses a different expiry-date format, get in touch. I’d like to hear what you’ve run into.

If you’re a solo web developer and want a hand keeping track of client sites, domains, hosting, and renewals, you can try Yven free for 21 days.