PHP has everything you need to implement the DNS protocol from scratch. No third-party libraries, no compiled extensions beyond the sockets extension that ships with most PHP installs. Just pack() to construct binary packets, socket_create() to open a UDP socket, and unpack() to tear the response apart byte by byte.
I built this because I wanted to understand what actually happens between the moment you type a domain name and the moment an IP address comes back. PHP's built-in dns_get_record() hides all of that behind a single function call. Writing a resolver from scratch strips away the abstraction and forces you to confront every byte in the wire format.
In this tutorial, I will walk through the complete implementation: encoding domain names into the wire format, building a conformant query packet, sending it over UDP to a recursive resolver, and parsing the binary response — including DNS name compression. If you want the conceptual foundation first, read my guide on how DNS queries work before continuing.
By the end, you will have a working DNS resolver in under 150 lines of PHP that can query A, AAAA, MX, CNAME, NS, TXT, and SOA records against any DNS server.
DNS Packet Structure Recap
Every DNS message — query or response — follows the same binary format defined in RFC 1035. The structure is:
- Header (12 bytes, fixed) — Transaction ID, flags, and section counts.
- Question section — The domain name and record type being queried.
- Answer section — Resource records that answer the query.
- Authority section — NS records pointing to authoritative servers.
- Additional section — Extra records (often glue A records).
The header is always exactly 12 bytes. Everything after that is variable-length. The question section uses a length-prefixed encoding for domain names. The answer section uses the same encoding but adds a twist: name compression, where pointers reference earlier positions in the packet to avoid repeating labels.
For a detailed byte-by-byte breakdown of the header flags, response codes, and section layout, see my DNS query protocol guide.
Record Type Constants
DNS record types are identified by numeric codes in the wire format. I define them as a constant map so the resolver can accept human-readable type names like 'MX' and convert them to the integer values that go into the packet:
const RECORD_TYPES = [
'A' => 1,
'AAAA' => 28,
'CNAME' => 5,
'MX' => 15,
'NS' => 2,
'TXT' => 16,
'SOA' => 6,
];
These cover the most common record types you will encounter. The full list lives in the IANA DNS Parameters registry, but these seven handle the vast majority of real-world lookups. You can extend this map with SRV => 33, CAA => 257, PTR => 12, or any other type you need.
Encoding a Domain Name
Domain names in DNS packets are not plain strings. Each label (the parts between dots) is prefixed with a single byte indicating its length, and the whole sequence is terminated by a null byte (\x00). So example.com becomes \x07example\x03com\x00 — seven bytes for "example", three bytes for "com", and a null terminator.
function encodeDomainName(string $domain): string
{
$domain = rtrim($domain, '.');
$parts = explode('.', $domain);
$result = '';
foreach ($parts as $part) {
$result .= chr(strlen($part)) . $part;
}
$result .= "\x00";
return $result;
}
The rtrim() handles fully qualified domain names that end with a trailing dot (like example.com.). Each label is limited to 63 bytes and the total domain name to 253 characters by the RFC, but I am skipping those validation checks to keep the code focused on the protocol.
Building the Query Packet
A DNS query packet consists of the 12-byte header followed by the question section. The header needs a random transaction ID (so I can match the response to the query), the Recursion Desired flag set, and a question count of 1. Everything else in the header is zero for a standard query.
function buildQuery(string $domain, int $recordType = 1): array
{
$id = random_int(0, 65535);
// Header: ID, Flags (RD=1), QDCOUNT=1, ANCOUNT=0, NSCOUNT=0, ARCOUNT=0
$header = pack('nnnnnn', $id, 0x0100, 1, 0, 0, 0);
// Question: encoded name + QTYPE + QCLASS (IN=1)
$question = encodeDomainName($domain) . pack('nn', $recordType, 1);
return ['packet' => $header . $question, 'id' => $id];
}
The pack('n', ...) format writes a 16-bit unsigned integer in network byte order (big-endian), which is exactly what DNS expects. The flags value 0x0100 sets only the RD (Recursion Desired) bit at position 8 — telling the server to do the full recursive resolution rather than returning a referral. The question section appends the record type and class (always 1 for IN — Internet class) after the encoded domain name.
I return both the assembled packet and the transaction ID so the resolver can verify the response matches.
Sending the Query
DNS queries use UDP on port 53. PHP's sockets extension gives you everything you need — create a UDP socket, set a receive timeout, send the packet, and wait for the response:
function sendQuery(string $packet, string $server = '8.8.8.8', int $timeout = 5): string
{
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($socket === false) {
throw new RuntimeException('Failed to create socket: ' . socket_strerror(socket_last_error()));
}
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, ['sec' => $timeout, 'usec' => 0]);
socket_sendto($socket, $packet, strlen($packet), 0, $server, 53);
$response = '';
$from = '';
$port = 0;
$bytes = socket_recvfrom($socket, $response, 4096, 0, $from, $port);
socket_close($socket);
if ($bytes === false) {
throw new RuntimeException('DNS query timed out');
}
return $response;
}
A few things to note:
AF_INETandSOCK_DGRAMspecify IPv4 and UDP respectively. For IPv6 servers, you would useAF_INET6.- 4096 bytes is more than enough for a standard DNS response. The original RFC 1035 limit is 512 bytes for UDP; EDNS0 extends it up to 4096.
- The timeout is critical. Without it,
socket_recvfrom()blocks indefinitely if the server never responds. socket_close()runs unconditionally. In production, you would wrap this in a try/finally to avoid leaking sockets on exceptions.
The $from and $port variables receive the source address and port of the response, which you could use to verify the response came from the server you queried.
Parsing the Response Header
The response uses the same 12-byte header format as the query. I unpack it and extract the individual flag bits:
function parseHeader(string $response): array
{
$header = unpack('nid/nflags/nqdcount/nancount/nnscount/narcount', substr($response, 0, 12));
return [
'id' => $header['id'],
'flags' => $header['flags'],
'qr' => ($header['flags'] >> 15) & 1,
'aa' => ($header['flags'] >> 10) & 1,
'tc' => ($header['flags'] >> 9) & 1,
'rcode' => $header['flags'] & 0xF,
'qdcount' => $header['qdcount'],
'ancount' => $header['ancount'],
'nscount' => $header['nscount'],
'arcount' => $header['arcount'],
];
}
The key fields:
- QR (bit 15) — 0 for query, 1 for response. If this is not 1, something went wrong.
- AA (bit 10) — Authoritative Answer. Set when the responding server is authoritative for the queried domain.
- TC (bit 9) — Truncation. If set, the response was too large for UDP and you should retry over TCP.
- RCODE (bits 0-3) — The response code. 0 means success, 3 means NXDOMAIN (domain does not exist), 2 means SERVFAIL.
- ANCOUNT — The number of answer records. This tells the parser how many resource records to extract after the question section.
Decoding Compressed Domain Names
This is the trickiest part of parsing DNS responses. To save space, DNS servers use name compression: instead of repeating a domain name that already appeared earlier in the packet, they insert a two-byte pointer that references the earlier occurrence.
A compression pointer is identified by the two highest bits of the length byte being set (0xC0). The remaining 14 bits give the byte offset from the start of the message where the name (or a suffix of it) was previously encoded.
function decodeDomainName(string $response, int $offset): array
{
$labels = [];
$jumped = false;
$maxOffset = $offset;
while (true) {
if ($offset >= strlen($response)) break;
$length = ord($response[$offset]);
if ($length === 0) {
if (!$jumped) $maxOffset = $offset + 1;
break;
}
if (($length & 0xC0) === 0xC0) {
if (!$jumped) $maxOffset = $offset + 2;
$pointer = unpack('n', substr($response, $offset, 2))[1] & 0x3FFF;
$offset = $pointer;
$jumped = true;
} else {
$offset++;
$labels[] = substr($response, $offset, $length);
$offset += $length;
}
}
return ['name' => implode('.', $labels), 'offset' => $maxOffset];
}
The $jumped flag is essential. When the parser follows a compression pointer, the read position jumps to an earlier part of the packet, but the next record in the response starts right after the two-byte pointer — not after wherever the pointer leads. The $maxOffset tracks where the caller should resume reading.
In PHP terms, the response is just a binary string. ord($response[$offset]) reads a single byte as an integer. substr($response, $offset, 2) extracts two bytes that I then unpack() as a 16-bit integer. The bitwise AND with 0x3FFF masks off the two high bits (the compression flag) to get the actual pointer value.
This function handles nested compression pointers correctly — a pointer can point to a name that itself contains a pointer. The loop continues until it hits a null terminator (\x00), signaling the end of the name.
Parsing Resource Records
Each resource record in the answer, authority, and additional sections follows the same format: a domain name, then 10 bytes of fixed fields (type, class, TTL, data length), then the variable-length record data. The interpretation of the data depends on the record type.
function parseRecord(string $response, int $offset): array
{
$decoded = decodeDomainName($response, $offset);
$name = $decoded['name'];
$offset = $decoded['offset'];
$fields = unpack('ntype/nclass/Nttl/nrdlength', substr($response, $offset, 10));
$rdataOffset = $offset + 10;
$rdlength = $fields['rdlength'];
$type = $fields['type'];
if ($type === 1) { // A
$value = inet_ntop(substr($response, $rdataOffset, 4));
} elseif ($type === 28) { // AAAA
$value = inet_ntop(substr($response, $rdataOffset, 16));
} elseif ($type === 2 || $type === 5) { // NS, CNAME
$value = decodeDomainName($response, $rdataOffset)['name'];
} elseif ($type === 15) { // MX
$priority = unpack('n', substr($response, $rdataOffset, 2))[1];
$exchange = decodeDomainName($response, $rdataOffset + 2)['name'];
$value = "$priority $exchange";
} elseif ($type === 16) { // TXT
$txtLen = ord($response[$rdataOffset]);
$value = substr($response, $rdataOffset + 1, $txtLen);
} else {
$value = bin2hex(substr($response, $rdataOffset, $rdlength));
}
return [
'record' => [
'name' => $name,
'type' => $type,
'class' => $fields['class'],
'ttl' => $fields['ttl'],
'value' => $value,
],
'offset' => $rdataOffset + $rdlength,
];
}
A few implementation notes:
inet_ntop()converts raw 4-byte (IPv4) or 16-byte (IPv6) binary data into a human-readable IP address string. This is cleaner than manually extracting octets withord().- The
Nformat inunpack()reads a 32-bit unsigned integer in big-endian byte order — exactly right for the TTL field, which represents the number of seconds a record can be cached. - MX records contain a 16-bit priority value followed by a compressed domain name for the mail exchange server.
- TXT records use their own length-prefixed encoding: the first byte is the string length, followed by the string data. Note that a single TXT record can contain multiple character strings, each with its own length prefix. This simplified implementation reads only the first string — sufficient for most SPF, DKIM, and DMARC lookups.
- The fallback case (
bin2hex) handles any record type I have not explicitly coded by dumping the raw data as hex. You could extend the type-specific branches to cover SOA, SRV, CAA, or any other type.
Putting It All Together
Now I tie all the functions together into a single resolve() function that takes a domain name and record type, sends the query, validates the response, and returns the parsed records:
function resolve(string $domain, string $recordType = 'A', string $server = '8.8.8.8'): array
{
$typeNum = RECORD_TYPES[strtoupper($recordType)] ?? 1;
$query = buildQuery($domain, $typeNum);
$response = sendQuery($query['packet'], $server);
$header = parseHeader($response);
if ($header['id'] !== $query['id']) {
throw new RuntimeException('Transaction ID mismatch');
}
if ($header['rcode'] === 3) {
throw new RuntimeException("NXDOMAIN: $domain does not exist");
}
if ($header['rcode'] !== 0) {
throw new RuntimeException("DNS error: RCODE {$header['rcode']}");
}
// Skip question section
$offset = 12;
for ($i = 0; $i < $header['qdcount']; $i++) {
$decoded = decodeDomainName($response, $offset);
$offset = $decoded['offset'] + 4; // QTYPE + QCLASS
}
// Parse answer records
$records = [];
$typeNames = array_flip(RECORD_TYPES);
for ($i = 0; $i < $header['ancount']; $i++) {
$parsed = parseRecord($response, $offset);
$parsed['record']['typeName'] = $typeNames[$parsed['record']['type']] ?? (string)$parsed['record']['type'];
$records[] = $parsed['record'];
$offset = $parsed['offset'];
}
return $records;
}
The function performs three validation checks before parsing:
- Transaction ID mismatch — If the response ID does not match the query ID, this response is not for my query. This is a basic security check against spoofed responses.
- NXDOMAIN (RCODE 3) — The domain does not exist. I throw a specific exception so the caller can handle it differently from other errors.
- Other errors — Any non-zero RCODE indicates an error. RCODE 2 (SERVFAIL) means the server failed to process the query. RCODE 5 (REFUSED) means the server declined to answer.
After validation, I skip the question section (it is echoed back in the response but not needed) and parse each answer record. The $typeNames array (created by flipping RECORD_TYPES) maps the numeric type back to a human-readable name for each record.
Testing It
Save all the functions in a single PHP file and run some queries:
// A record lookup
$records = resolve('google.com', 'A');
foreach ($records as $r) {
printf("%s %d %s %s\n", $r['name'], $r['ttl'], $r['typeName'], $r['value']);
}
// google.com 300 A 142.250.80.46
// MX records
$records = resolve('gmail.com', 'MX');
foreach ($records as $r) {
printf("%s %d %s %s\n", $r['name'], $r['ttl'], $r['typeName'], $r['value']);
}
// gmail.com 3600 MX 5 gmail-smtp-in.l.google.com
// gmail.com 3600 MX 10 alt1.gmail-smtp-in.l.google.com
// ...
// TXT record (SPF)
$records = resolve('google.com', 'TXT');
foreach ($records as $r) {
printf("%s %d %s %s\n", $r['name'], $r['ttl'], $r['typeName'], $r['value']);
}
// Query a specific nameserver
$records = resolve('example.com', 'A', '1.1.1.1');
foreach ($records as $r) {
printf("%s %d %s %s\n", $r['name'], $r['ttl'], $r['typeName'], $r['value']);
}
// example.com 86400 A 93.184.215.14
Compare the output against the dig command or my DNS Inspector tool to verify your resolver is returning correct results. For checking how records look across different global nameservers, the Propagation Checker is useful.
Complete Resolver Class
For cleaner usage, here is the entire resolver wrapped in a class. This is the same code from above reorganized into an OOP structure that you can drop into any project:
<?php
declare(strict_types=1);
class DnsResolver
{
private const RECORD_TYPES = [
'A' => 1,
'AAAA' => 28,
'CNAME' => 5,
'MX' => 15,
'NS' => 2,
'TXT' => 16,
'SOA' => 6,
];
public function __construct(
private string $server = '8.8.8.8',
private int $timeout = 5
) {}
public function resolve(string $domain, string $type = 'A'): array
{
$typeNum = self::RECORD_TYPES[strtoupper($type)] ?? 1;
$query = $this->buildQuery($domain, $typeNum);
$response = $this->sendQuery($query['packet']);
$header = $this->parseHeader($response);
if ($header['id'] !== $query['id']) {
throw new RuntimeException('Transaction ID mismatch');
}
if ($header['rcode'] === 3) {
throw new RuntimeException("NXDOMAIN: $domain does not exist");
}
if ($header['rcode'] !== 0) {
throw new RuntimeException("DNS error: RCODE {$header['rcode']}");
}
$offset = 12;
for ($i = 0; $i < $header['qdcount']; $i++) {
$decoded = $this->decodeDomainName($response, $offset);
$offset = $decoded['offset'] + 4;
}
$records = [];
$typeNames = array_flip(self::RECORD_TYPES);
for ($i = 0; $i < $header['ancount']; $i++) {
$parsed = $this->parseRecord($response, $offset);
$parsed['record']['typeName'] = $typeNames[$parsed['record']['type']] ?? (string)$parsed['record']['type'];
$records[] = $parsed['record'];
$offset = $parsed['offset'];
}
return $records;
}
private function encodeDomainName(string $domain): string
{
$domain = rtrim($domain, '.');
$parts = explode('.', $domain);
$result = '';
foreach ($parts as $part) {
$result .= chr(strlen($part)) . $part;
}
$result .= "\x00";
return $result;
}
private function buildQuery(string $domain, int $recordType): array
{
$id = random_int(0, 65535);
$header = pack('nnnnnn', $id, 0x0100, 1, 0, 0, 0);
$question = $this->encodeDomainName($domain) . pack('nn', $recordType, 1);
return ['packet' => $header . $question, 'id' => $id];
}
private function sendQuery(string $packet): string
{
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
if ($socket === false) {
throw new RuntimeException('Failed to create socket: ' . socket_strerror(socket_last_error()));
}
socket_set_option($socket, SOL_SOCKET, SO_RCVTIMEO, [
'sec' => $this->timeout,
'usec' => 0,
]);
socket_sendto($socket, $packet, strlen($packet), 0, $this->server, 53);
$response = '';
$from = '';
$port = 0;
$bytes = socket_recvfrom($socket, $response, 4096, 0, $from, $port);
socket_close($socket);
if ($bytes === false) {
throw new RuntimeException('DNS query timed out');
}
return $response;
}
private function parseHeader(string $response): array
{
$header = unpack('nid/nflags/nqdcount/nancount/nnscount/narcount', substr($response, 0, 12));
return [
'id' => $header['id'],
'flags' => $header['flags'],
'qr' => ($header['flags'] >> 15) & 1,
'aa' => ($header['flags'] >> 10) & 1,
'tc' => ($header['flags'] >> 9) & 1,
'rcode' => $header['flags'] & 0xF,
'qdcount' => $header['qdcount'],
'ancount' => $header['ancount'],
'nscount' => $header['nscount'],
'arcount' => $header['arcount'],
];
}
private function decodeDomainName(string $response, int $offset): array
{
$labels = [];
$jumped = false;
$maxOffset = $offset;
while (true) {
if ($offset >= strlen($response)) break;
$length = ord($response[$offset]);
if ($length === 0) {
if (!$jumped) $maxOffset = $offset + 1;
break;
}
if (($length & 0xC0) === 0xC0) {
if (!$jumped) $maxOffset = $offset + 2;
$pointer = unpack('n', substr($response, $offset, 2))[1] & 0x3FFF;
$offset = $pointer;
$jumped = true;
} else {
$offset++;
$labels[] = substr($response, $offset, $length);
$offset += $length;
}
}
return ['name' => implode('.', $labels), 'offset' => $maxOffset];
}
private function parseRecord(string $response, int $offset): array
{
$decoded = $this->decodeDomainName($response, $offset);
$name = $decoded['name'];
$offset = $decoded['offset'];
$fields = unpack('ntype/nclass/Nttl/nrdlength', substr($response, $offset, 10));
$rdataOffset = $offset + 10;
$rdlength = $fields['rdlength'];
$type = $fields['type'];
if ($type === 1) {
$value = inet_ntop(substr($response, $rdataOffset, 4));
} elseif ($type === 28) {
$value = inet_ntop(substr($response, $rdataOffset, 16));
} elseif ($type === 2 || $type === 5) {
$value = $this->decodeDomainName($response, $rdataOffset)['name'];
} elseif ($type === 15) {
$priority = unpack('n', substr($response, $rdataOffset, 2))[1];
$exchange = $this->decodeDomainName($response, $rdataOffset + 2)['name'];
$value = "$priority $exchange";
} elseif ($type === 16) {
$txtLen = ord($response[$rdataOffset]);
$value = substr($response, $rdataOffset + 1, $txtLen);
} else {
$value = bin2hex(substr($response, $rdataOffset, $rdlength));
}
return [
'record' => [
'name' => $name,
'type' => $type,
'class' => $fields['class'],
'ttl' => $fields['ttl'],
'value' => $value,
],
'offset' => $rdataOffset + $rdlength,
];
}
}
Usage is straightforward:
$resolver = new DnsResolver('8.8.8.8');
$records = $resolver->resolve('example.com', 'A');
print_r($records);
// Use Cloudflare's resolver
$resolver = new DnsResolver('1.1.1.1');
$records = $resolver->resolve('github.com', 'AAAA');
print_r($records);
What Is Missing from Production
This resolver handles the happy path well, but a production-grade implementation needs several more features:
- TCP fallback — When the response has the TC (Truncation) flag set, the resolver should retry the query over TCP. TCP DNS uses the same packet format but prefixes each message with a 2-byte length field.
- EDNS0 — Adding an OPT pseudo-record to the additional section advertises a larger UDP buffer size (typically 4096 bytes) and enables features like DNSSEC validation.
- Response caching — A real resolver caches answers using the TTL value from each record, avoiding redundant network round-trips.
- DNSSEC validation — Verifying the chain of trust from the root zone down to the queried domain using DNSIG and DS records. See my article on what DNSSEC is and how it works for the conceptual background.
- Retry logic — Retrying with a different server or increasing the timeout on failure.
- Multiple question sections — While uncommon, the DNS spec allows multiple questions in a single query.
For production PHP applications, dns_get_record() or a library like Net_DNS2 handles all of this. For a broader look at DNS lookup approaches in PHP, see DNS Lookups in PHP: dns_get_record and Beyond. The point of building from scratch is understanding the protocol — and now you do.
If you want to see how real DNS responses look across different servers worldwide, try the DNS Inspector to query specific record types against specific nameservers, or the Propagation Checker to see how a record resolves from dozens of global locations simultaneously.
