Building a PHP Domain Monitor With Registration Dates and DNS Checks

Building a PHP Domain Monitor With Registration Dates and DNS Checks

A domain can keep its registration date for years while its DNS records change twice before lunch. If your monitor mixes those events, a routine hosting move can look like a change in domain ownership. A useful PHP monitor keeps both histories, gives every observation a clear timestamp, and tells you what actually changed.

We will build a small command-line application with PHP, SQLite, and cron. It will import registration events from a saved RDAP response, convert their timestamps to UTC, and record scheduled DNS checks. The result is a readable history you can extend with alerts or a dashboard.

Your Monitor’s Three Jobs

  1. Store registration events with their source and original timestamp.
  2. Convert accepted timestamps to UTC before saving them.
  3. Check DNS on a schedule and record changes separately.

Give Each Kind of Evidence Its Own Home

Registration data describes a domain registration object. DNS data describes the records returned by a DNS lookup. These sources answer different questions, even when their dates happen to line up.

Keep the event timestamp separate from the observation timestamp. A registration event may have happened years ago. Your script might have learned about it this morning. Saving both dates prevents your application from confusing historical events with new observations.

For this project, prepare the following:

  • A supported PHP installation with PDO SQLite and DNS functions available.
  • A private application directory writable by the scheduled job.
  • A saved JSON response from a trusted RDAP service for your domain.
  • Access to cron and a location for application logs.

Run the application outside your public web directory. Registration responses and operational logs do not need to be downloadable. Use ASCII domain names in this first version. Convert internationalized names to their ASCII representation before adding them.

Create a Small Database With Clear Timestamps

Create bootstrap.php with the following code. Every script will load this file, so database setup and shared helpers stay in one place.

<?php
declare(strict_types=1);

date_default_timezone_set('UTC');

$db = new PDO('sqlite:' . __DIR__ . '/monitor.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA foreign_keys = ON');
$db->exec('PRAGMA busy_timeout = 5000');

$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS domains (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS registration_events (
    id INTEGER PRIMARY KEY,
    domain_id INTEGER NOT NULL REFERENCES domains(id),
    action TEXT NOT NULL,
    original_timestamp TEXT NOT NULL,
    event_at_utc TEXT NOT NULL,
    source TEXT NOT NULL,
    observed_at_utc TEXT NOT NULL,
    UNIQUE(domain_id, action, event_at_utc, source)
);

CREATE TABLE IF NOT EXISTS dns_checks (
    id INTEGER PRIMARY KEY,
    domain_id INTEGER NOT NULL REFERENCES domains(id),
    checked_at_utc TEXT NOT NULL,
    status TEXT NOT NULL,
    records_json TEXT,
    fingerprint TEXT,
    changed INTEGER NOT NULL DEFAULT 0
);
SQL);

function utcNow(): string
{
    return (new DateTimeImmutable('now', new DateTimeZone('UTC')))
        ->format('Y-m-d\TH:i:s\Z');
}

function domainId(PDO $db, string $name): int
{
    $name = strtolower(rtrim(trim($name), '.'));

    if (!str_contains($name, '.') ||
        filter_var(
            $name,
            FILTER_VALIDATE_DOMAIN,
            FILTER_FLAG_HOSTNAME
        ) === false) {
        throw new InvalidArgumentException('Invalid domain name.');
    }

    $insert = $db->prepare(
        'INSERT OR IGNORE INTO domains (name) VALUES (?)'
    );
    $insert->execute([$name]);

    $select = $db->prepare(
        'SELECT id FROM domains WHERE name = ?'
    );
    $select->execute([$name]);

    return (int) $select->fetchColumn();
}

The database uses text timestamps in one fixed UTC format. That makes stored dates readable during debugging and gives them consistent lexical ordering. The original registration timestamp remains available for auditing.

The uniqueness rule prevents repeated imports from adding the same event again. Its observation timestamp therefore records when this monitor first imported that event. If you need every registration lookup attempt, add a separate fetch-history record instead of changing that meaning.

Normalize Registration Dates Without Guessing

A timestamp with an explicit offset describes a moment you can convert to UTC. A timestamp without an offset needs additional context. Do not silently assume your server’s timezone is the source timezone.

Add this function to bootstrap.php. It accepts a deliberately limited RFC 3339 timestamp format, including optional fractional seconds. It rejects missing offsets, invalid calendar values, and the unknown-offset marker -00:00.

function normalizeEventDate(string $value): string
{
    $pattern = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'
        . '(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D';

    if (!preg_match($pattern, $value) ||
        str_ends_with($value, '-00:00')) {
        throw new InvalidArgumentException(
            'Expected a timestamp with a known timezone offset.'
        );
    }

    $input = str_ends_with($value, 'Z')
        ? substr($value, 0, -1) . '+00:00'
        : $value;

    $format = str_contains($input, '.')
        ? '!Y-m-d\TH:i:s.uP'
        : '!Y-m-d\TH:i:sP';

    $date = DateTimeImmutable::createFromFormat($format, $input);
    $errors = DateTimeImmutable::getLastErrors();

    if ($date === false ||
        ($errors !== false &&
         ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
        throw new InvalidArgumentException('Invalid event date.');
    }

    return $date->setTimezone(new DateTimeZone('UTC'))
        ->format('Y-m-d\TH:i:s\Z');
}

For example, 2025-04-10T14:30:00+02:00 becomes 2025-04-10T12:30:00Z. That conversion changes the displayed clock time while preserving the represented moment.

This application stores normalized values to whole seconds. Fractional precision remains in the original timestamp. If subsecond differences matter to your application, use a consistent fractional format throughout the database instead.

Import Registration Events From a Trusted Response

RDAP responses can include an events array containing event labels and dates. Preserve those labels. A registration event, an expiration event, and a last-changed event should never share one ambiguous “domain date” field.

Create import-registration.php. Pass it a domain, a saved response file, and the source endpoint used to obtain that response.

<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

if ($argc !== 4) {
    fwrite(
        STDERR,
        "Usage: php import-registration.php domain file source\n"
    );
    exit(1);
}

[, $domain, $file, $source] = $argv;

if (!is_readable($file)) {
    throw new RuntimeException('Response file is not readable.');
}

$data = json_decode(
    file_get_contents($file),
    true,
    512,
    JSON_THROW_ON_ERROR
);

$expected = strtolower(rtrim(trim($domain), '.'));
$returned = strtolower(rtrim((string) ($data['ldhName'] ?? ''), '.'));

if ($returned !== $expected) {
    throw new RuntimeException('Response domain does not match.');
}

$events = $data['events'] ?? [];

if (!is_array($events)) {
    throw new RuntimeException('Invalid events collection.');
}

$id = domainId($db, $expected);
$observed = utcNow();

$insert = $db->prepare(
    'INSERT OR IGNORE INTO registration_events
     (domain_id, action, original_timestamp, event_at_utc,
      source, observed_at_utc)
     VALUES (?, ?, ?, ?, ?, ?)'
);

$db->beginTransaction();

try {
    foreach ($events as $event) {
        if (!is_array($event) ||
            !is_string($event['eventAction'] ?? null) ||
            !is_string($event['eventDate'] ?? null)) {
            throw new RuntimeException('Invalid event structure.');
        }

        $insert->execute([
            $id,
            $event['eventAction'],
            $event['eventDate'],
            normalizeEventDate($event['eventDate']),
            $source,
            $observed,
        ]);
    }

    $db->commit();
} catch (Throwable $error) {
    $db->rollBack();
    throw $error;
}

This importer reads a file rather than fetching an arbitrary address. That keeps network access out of the example and makes imports easy to reproduce. Obtain the response from the appropriate trusted service, retain the original file, and record its actual source.

Reading about domain registration activity helps put these lifecycle events in context: a creation date concerns the registration object, while a last-changed date does not identify which field changed. Neither timestamp proves that a DNS record changed.

Keep that distinction visible in your own interface. Label a registration date as a registration event. Label a DNS difference as a difference observed during a check. An expiration date also needs its source label and context. It is not a promise that a domain will become available at that moment.

Compare DNS Answers Without Order Noise

DNS responses can return the same addresses in a different order. Hashing the raw response would produce false change reports. TTL values also fall as cached records age, so this monitor leaves TTL out of its content comparison.

Create check-dns.php. This version tracks IPv4 addresses, IPv6 addresses, mail exchangers, and nameservers. It sorts and removes duplicate values before calculating a fingerprint.

<?php
declare(strict_types=1);

require __DIR__ . '/bootstrap.php';

function dnsValues(array $records): array
{
    $values = [];

    foreach ($records as $record) {
        $type = $record['type'] ?? '';

        $value = match ($type) {
            'A' => inet_ntop(inet_pton($record['ip'])),
            'AAAA' => inet_ntop(inet_pton($record['ipv6'])),
            'MX' => (string) $record['pri'] . ' ' .
                strtolower(rtrim($record['target'], '.')),
            'NS' => strtolower(rtrim($record['target'], '.')),
            default => null,
        };

        if ($value !== null) {
            $values[] = $type . ' ' . $value;
        }
    }

    $values = array_values(array_unique($values));
    sort($values, SORT_STRING);

    return $values;
}

$previous = $db->prepare(
    "SELECT fingerprint FROM dns_checks
     WHERE domain_id = ? AND status = 'ok'
     ORDER BY id DESC LIMIT 1"
);

$save = $db->prepare(
    'INSERT INTO dns_checks
     (domain_id, checked_at_utc, status,
      records_json, fingerprint, changed)
     VALUES (?, ?, ?, ?, ?, ?)'
);

$failed = false;

foreach ($db->query('SELECT id, name FROM domains') as $domain) {
    $checked = utcNow();
    $status = 'ok';
    $json = null;
    $fingerprint = null;
    $changed = 0;

    try {
        $records = dns_get_record(
            $domain['name'],
            DNS_A | DNS_AAAA | DNS_MX | DNS_NS
        );

        if ($records === false) {
            throw new RuntimeException('DNS lookup failed.');
        }

        $json = json_encode(
            dnsValues($records),
            JSON_THROW_ON_ERROR
        );
        $fingerprint = hash('sha256', $json);

        $previous->execute([$domain['id']]);
        $old = $previous->fetchColumn();

        $changed = (int) (
            $old !== false && $old !== $fingerprint
        );
    } catch (Throwable $error) {
        $status = 'error';
        $failed = true;

        fwrite(
            STDERR,
            $checked . ' ' . $domain['name'] . ' ' .
            $error->getMessage() . PHP_EOL
        );
    }

    $save->execute([
        $domain['id'],
        $checked,
        $status,
        $json,
        $fingerprint,
        $changed,
    ]);
}

exit($failed ? 1 : 0);

The first successful lookup establishes a baseline. Later successful lookups compare against the most recent successful result. A failed lookup gets its own error row and does not replace that baseline.

An empty successful answer is stored as an empty record set. That is different from a function-level failure. Still, this PHP function does not provide the detailed response codes and diagnostics needed to classify every DNS problem. Use a DNS client with explicit response-code handling if you need to distinguish negative answers, validation failures, and transport errors precisely.

These checks reflect answers available through the machine’s configured resolver. Cached data, geographic routing, and resolver behavior can affect results. A changed fingerprint reports a changed observation, not the exact time someone edited the authoritative zone.

Schedule Checks With a Lock and a Log

Test each stage manually before adding cron. Use this sequence:

  1. Import a saved response and confirm that its domain name matches.
  2. Inspect the registration rows and verify their UTC conversions.
  3. Run the DNS checker twice and confirm that stable answers stay unchanged.
  4. Change a record on a domain you control, then check again after caches expire.
  5. Add the scheduled job and inspect its output after the first run.

On a Linux system with flock and timeout available, this cron entry checks every fifteen minutes. Adjust the paths to match your installation.

*/15 * * * * /usr/bin/flock -n /srv/domain-monitor/check.lock /usr/bin/timeout 120 /usr/bin/php /srv/domain-monitor/check-dns.php >> /srv/domain-monitor/monitor.log 2>&1

The lock prevents overlapping runs that use this entry. The timeout limits the total run duration. If the timeout terminates the process, that run can be incomplete, so monitor job exit status as well as database rows.

The schedule runs according to cron’s configured timezone. The PHP timestamps remain UTC because the application sets that explicitly. Fifteen-minute spacing also means you can miss a change that appears and disappears between checks.

Registration imports have a separate cadence. Daily retrieval may suit a small portfolio, subject to the service’s access rules and rate limits. Automating retrieval requires trusted endpoint selection, request timeouts, response validation, and retained source responses. The file importer already gives that future retrieval step a clear destination.

Make Change Reports Useful to a Human

Start with a database query before building a dashboard. This shows recent DNS differences and lookup errors.

SELECT
    domains.name,
    dns_checks.checked_at_utc,
    dns_checks.status,
    dns_checks.changed,
    dns_checks.records_json
FROM dns_checks
JOIN domains ON domains.id = dns_checks.domain_id
WHERE dns_checks.changed = 1 OR dns_checks.status = 'error'
ORDER BY dns_checks.id DESC
LIMIT 50;

An alert should include the domain, observation time, and the previous and current record sets. That gives you enough information to recognize a planned mail migration or investigate an unexpected address.

Avoid sending the same failure message every fifteen minutes. Track alert state separately, notify when a problem begins, and send a recovery message when a successful check returns. For variable DNS answers, consider requiring confirmation from another check before escalating.

Test timestamp handling with equivalent moments expressed through different offsets. Also test invalid dates, a mismatched registration response, duplicate imports, reordered DNS answers, empty results, and lookup failures. Those checks target the mistakes most likely to distort your history.

Keep a Timeline You Can Trust Later

A useful domain monitor answers two questions clearly: what did the source report, and when did your application observe it? Registration events retain their labels and provenance. DNS checks retain their own timestamps and results. UTC gives both histories a shared clock.

Keep the first version small enough to inspect. Run it against a domain you control, watch a real record change pass through the system, and review the saved evidence. Once that path works, alerts and a browser interface become easier additions because the underlying history already has clear meaning.

Tags:

Categories:

No Responses

Leave a Reply

Your email address will not be published. Required fields are marked *