DNS Monitoring in PHP: Detect Record Changes via Cron

Updated: 6 min read Editorial team The PHP Experts
Short answer

DNS monitoring in PHP means querying a domain's important records with dns_get_record() on a schedule, storing them normalised as JSON and comparing each run with the previous one. When an A, MX, NS or TXT record changes, the script sends an alert. A cron job every five to fifteen minutes is enough for most projects.

A familiar failure pattern: a team moves to a new mail provider, sets everything up correctly, and someone deletes the old DKIM record before the new one is published. Nothing is down, so nothing alerts, and newsletters drift into spam folders for days. A small DNS monitor would flag that within one cron interval.

What DNS monitoring in PHP should catch

The question is simple: did any record that matters for web or mail change since the last run? Answering it reliably takes a few deliberate choices:

RequirementHow
no alerts from record ordersort values per type
no alerts from TTL countdowndon’t store the TTL
failures distinct from changesreport false from dns_get_record() separately
readable alerts+ and - lines per value

The behaviour of dns_get_record() itself, including why false and an empty array mean very different things, is covered in PHP dns_get_record. The monitor builds on it.

Taking a normalised snapshot

snapshot() queries five record types and reduces each record to a plain string. The TTL is dropped because it counts down between calls and would otherwise make every run look like a change. TXT values are joined from entries so long SPF and DKIM records compare cleanly.

<?php
declare(strict_types=1);

const TYPES = ['A' => DNS_A, 'AAAA' => DNS_AAAA, 'MX' => DNS_MX, 'NS' => DNS_NS, 'TXT' => DNS_TXT];

/** Sorted, normalised values per record type. TTL intentionally omitted. */
function snapshot(string $host): array
{
    $snap = [];
    foreach (TYPES as $name => $type) {
        $records = @dns_get_record($host, $type);
        if ($records === false) {
            throw new RuntimeException("DNS lookup $name for $host failed");
        }
        $values = array_map(fn(array $r): string => match ($r['type']) {
            'A'     => $r['ip'],
            'AAAA'  => $r['ipv6'],
            'MX'    => $r['pri'] . ' ' . $r['target'],
            'NS'    => strtolower($r['target']),
            'TXT'   => implode('', $r['entries']),
            default => json_encode($r),
        }, $records);
        sort($values);
        $snap[$name] = array_values(array_unique($values));
    }
    return $snap;
}

/** @return list<string> human-readable changes */
function diff(array $old, array $new): array
{
    $changes = [];
    foreach (array_keys(TYPES) as $type) {
        $before = $old[$type] ?? [];
        $after  = $new[$type] ?? [];
        foreach (array_diff($after, $before) as $v) {
            $changes[] = "+ $type $v";
        }
        foreach (array_diff($before, $after) as $v) {
            $changes[] = "- $type $v";
        }
    }
    return $changes;
}

CAA isn’t in the list on purpose. While testing, a CAA query to one slow nameserver blocked for more than a minute, and dns_get_record() has no timeout argument. If you want CAA, give it its own, less frequent job.

The cron script

The main loop loads the stored state per host, compares and writes the new snapshot. Each host is queried twice, and the run continues only if both results agree. That absorbs round-robin shuffles and one-off resolver hiccups without any threshold tuning.

<?php
declare(strict_types=1);

$hosts   = ['php.net', 'example.com'];
$dir     = __DIR__ . '/dns-state';
$changes = [];
is_dir($dir) || mkdir($dir, 0700, true);

foreach ($hosts as $host) {
    $file = "$dir/$host.json";
    try {
        // query twice so round-robin and transient resolver errors don't alert
        $first  = snapshot($host);
        $second = snapshot($host);
        if ($first !== $second) {
            continue;
        }
    } catch (RuntimeException $e) {
        $changes[$host] = ['ERROR: ' . $e->getMessage()];
        continue;
    }
    if (is_file($file)) {
        $old  = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
        $diff = diff($old, $first);
        if ($diff !== []) {
            $changes[$host] = $diff;
        }
    }
    file_put_contents($file, json_encode($first, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}

if ($changes !== []) {
    $text = '';
    foreach ($changes as $host => $lines) {
        $text .= "$host\n  " . implode("\n  ", $lines) . "\n";
    }
    echo $text;
    exit(1);
}
echo "no changes\n";

To test the diff I edited the stored snapshot for php.net and replaced its MX with an invented value. The next run printed:

php.net
  + MX 0 php-smtp4-ip4.php.net
  - MX 10 mail.alt.example

Exit code 1 on changes lets a CI job or health check pick it up. The crontab entry for every ten minutes:

*/10 * * * * /usr/bin/php /opt/dns-monitor/monitor.php >> /var/log/dns-monitor.log 2>&1

Sending the alert somewhere people look

mail() only works if the server has a working mailer, which many containers and small VPS images don’t. It then returns false and nobody notices. A webhook into the team chat is more dependable:

<?php
declare(strict_types=1);

function notify(string $webhookUrl, string $text): void
{
    $ch = curl_init($webhookUrl);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(['text' => "DNS change detected\n" . $text], JSON_THROW_ON_ERROR),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);
    if (curl_exec($ch) === false || curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 300) {
        // fallback: keep the alert in the log
        error_log('DNS monitor: webhook failed, payload: ' . $text);
    }
}

Slack and Mattermost incoming webhooks accept a text field; other services want different JSON. Keep the fallback either way. A monitor that swallows its own failures is worse than none, because it creates false confidence.

Making the cron job safe to run unattended

Because dns_get_record() has no timeout, one slow nameserver can stretch a run beyond the cron interval. Two overlapping runs then write the same snapshot files and can produce phantom diffs. A non-blocking lock at the top of the script prevents that:

<?php
declare(strict_types=1);

$lock = fopen(sys_get_temp_dir() . '/dns-monitor.lock', 'c');
if ($lock === false || flock($lock, LOCK_EX | LOCK_NB) === false) {
    fwrite(STDERR, "previous run still active, skipping\n");
    exit(0);
}
// ... monitoring code ...

The lock is released automatically when the process ends, so a crash doesn’t leave it stuck. Exiting with 0 here is deliberate: a skipped run isn’t a DNS change and shouldn’t page anyone.

It also helps to log a one-line summary on every run, including the “no changes” case. When the log stops growing, you know the monitor itself has stopped, which is the failure you’d otherwise discover last.

Keeping false positives down

Some hosts change constantly by design. CDN-backed names return different IPs per query; for those, monitor the CNAME or NS records rather than A. Services also add and remove verification TXT records; if you don’t care about them, filter TXT to prefixes such as v=spf1 and v=DMARC1.

Pick hosts deliberately. Beyond the apex, watch www, your API subdomain, _dmarc.<domain> and your DKIM selector, for example selector1._domainkey.<domain>. Those last two are the ones that break mail silently. And store dns-state outside the web root, since TXT values can include verification tokens.

Limits: caching and expiry dates

Your resolver caches, so a change shows up only once the old record’s TTL has expired: with a TTL of 3,600 seconds, up to an hour later. For faster detection, query the authoritative servers with pear/net_dns2, which accepts a nameserver list. The trade-off is more code and more queries against servers you don’t operate, so keep the interval sensible and cache the list of authoritative nameservers rather than resolving it on every run. For most teams, a delay of one TTL is perfectly acceptable.

DNS also says nothing about when a domain expires. A daily job using the client from RDAP in PHP, warning 30 days before the expiration event, covers most gTLDs. For mail-specific checks such as null MX, see checking MX records in PHP.

If your domains are registered with betadomain, auto-renewal can be switched on or off and you get a reminder before each renewal. The list_my_domains MCP tool returns your domains with expiry dates, as described in the developer docs. Need another name for a staging setup? Search for a free domain; an .app domain costs €22.99 a year.

Step by step

  1. Fetch and normalise recordsRead A, AAAA, MX, NS and TXT with dns_get_record(), drop the TTL and sort the values.
  2. Confirm with a second lookupBuild the snapshot twice and continue only if both match.
  3. Diff against the stored stateLoad the previous JSON snapshot, compute per-type differences with array_diff() and save the new state.
  4. Alert and scheduleSend an email or webhook when something changed and run the script from cron every five to fifteen minutes.

Found your domain? You can check and register it directly at register a domain – yearly price from €2.99.

FAQ

How often should DNS monitoring run?

Every five to fifteen minutes works well. Running more often gains little because your resolver caches answers for the TTL anyway. If you need to see changes instantly, query the authoritative nameservers directly.

Why does my monitor report changes nobody made?

Usually round-robin answers in varying order, CDNs rotating IP addresses, or brief resolver failures. Sorting values, querying twice and excluding CDN-backed A records removes most false alarms.

Can PHP also monitor domain expiry dates?

Yes, but not through DNS. Expiry dates live in RDAP or whois. A daily RDAP lookup that warns 30 days before the expiration event complements DNS monitoring well for most gTLDs.

Do I need an external monitoring service?

For a handful of domains a PHP script and cron are enough. External services make sense when you want checks from several regions, or when the server running your monitor could itself go down and leave nobody to raise the alarm.