Prefix WhoIs from PHP.
One file, no external software dependencies. Look up one address transactionally, or thousands in bulk, IPv4 or IPv6, and get arrays keyed by the server's own field names. For efficiency and to maximize daily query limits, it never asks the same question twice: duplicates are collapsed, and an optional 24-hour cache remembers every answer, so your daily allowance goes as far as it can. A quota call tells you where you stand.
Five functions, one include.
<?phprequire 'pwhois.php';// One address: the record as an array, or null if it is unrouted or invalid.$r = pwhois_lookup('4.2.2.1');echo "AS{$r['origin-as']} {$r['as-org-name']} announces {$r['prefix']}\n";// Many addresses in one round trip (up to 500 per batch; larger lists are batched).// Duplicates are collapsed, invalid entries come back as null, IPv6 is fine.$log = ['4.2.2.1', '2001:4860:4860::8888', '8.8.8.8', '10.0.0.1', '8.8.8.8'];foreach (pwhois_lookup_bulk($log) as $ip => $rec) { echo str_pad($ip, 24), $rec ? "AS{$rec['origin-as']} {$rec['as-org-name']}" : 'not routed', "\n";}// Optional: a 24-hour cache on disk, so an address is never asked about twice in a day.pwhois_cache_enable(); // SQLite file in the system temp dir by defaultpwhois_lookup_bulk($log); // served from the cache: costs nothing// Where you stand against the daily allowance of the address you query from.$q = pwhois_quota();echo "{$q['remaining']} of {$q['limit']} queries left today; resets {$q['resets_at']}\n";echo pwhois_usage(), " queries sent by this process today\n";
['type' => 'all'] adds the registry organization record. Costs one query, or nothing when cached.null. Afterwards $options['cached'] and $options['errors'] tell you what happened. One query per distinct address sent.pwhois_cache_stats() reports the backend, hits and misses; pwhois_cache_disable() turns it off.The 2005 entry points doPWLookup() and doPWLookupBulk() still work and keep their original return shape, so existing code needs no changes; new code should use the functions above.
What comes back
Run against the live service 0s ago. pwhois_lookup('4.2.2.1') returns:
{
"ip": "4.2.2.1",
"origin-as": "3356",
"prefix": "4.0.0.0/9",
"as-path": "8220 1299 3356",
"as-org-name": "Level 3 Parent, LLC",
"org-name": "Level 3 Parent, LLC",
"net-name": "LVLT-ORG-4-8",
"cache-date": "Oct 13 2026 07:40:44",
"latitude": "33.748795",
"longitude": "-84.387543",
"city": "Atlanta",
"region": "Georgia",
"country": "United States of America",
"country-code": "US",
"route-originated-date": "Oct 08 2026 14:36:31",
"route-originated-ts": "1791470191"
}The bulk call with a duplicate, an IPv6 address and private space:
4.2.2.1 AS3356 Level 3 Parent, LLC 4.0.0.0/9 2001:4860:4860::8888 AS15169 Google LLC 2001:4860::/32 8.8.8.8 AS15169 Google LLC 8.8.8.0/24 10.0.0.1 not routed
Keys in every record
ipThe address asked about, in canonical form.origin-asAS announcing the most specific covering prefix right now.prefixThat prefix, as it appears in the global table.as-pathThe AS path from the route server to the origin, origin last.as-org-name, org-name, net-nameOrganization registered for the origin AS, and the organization and network name registered for the address block.country, country-code, region, city, latitude, longitudeApproximate location from registry data. A network's registration, not a device's position.cache-dateWhen the answering server's routing table snapshot was taken.['type' => 'all']The registry organization record too: org-id, source, postal fields, register-date, update-date, contact handles.Values the server marks NULL arrive as PHP null. Field names are lower-cased with their hyphens kept, so they match the documented output line for line.
pwhois.php
<?php/** * Prefix WhoIs client library for PHP * * A small, dependency-free client for the Prefix WhoIs service (whois.pwhois.org): * one address at a time, or up to 500 per round trip in bulk mode. Answers come back * as arrays keyed by the field names the server uses, lower-cased ("origin-as", * "as-org-name", "prefix", "country-code", ...). * * require 'pwhois.php'; * * $one = pwhois_lookup('4.2.2.1'); // ?array * $many = pwhois_lookup_bulk(['4.2.2.1', '2001:4860:4860::8888', '10.0.0.1']); * // => ['4.2.2.1' => [...], '2001:4860:4860::8888' => [...], '10.0.0.1' => null] * * pwhois_cache_enable(); // optional: 24-hour LRU cache (SQLite, built into PHP) * $q = pwhois_quota(); // your address's daily limit, used, remaining * * Every query costs one unit of your daily allowance, so the library never asks twice: * duplicates in a batch are collapsed, and with the cache enabled an address already * answered today is served from disk. Requires PHP 7.4 or later (8.x recommended) with * stream sockets; nothing else. Public service limits apply: 5,000 queries per source * address per day by default. Higher limits are customarily granted free upon reasonable * request; see https://pwhois.org/contact.who?contact=request * * Copyright (c) 2005-2026 VOSTROM Holdings, Inc. Released under the same open source * license as the Prefix WhoIs software; see https://pwhois.org/license.who */declare(strict_types=1);const PWHOIS_SERVER = 'whois.pwhois.org';const PWHOIS_PORT = 43;const PWHOIS_APP = 'pwhois.php/2.0';const PWHOIS_USER_AGENT = 'pwhois.php/2.0 (VOSTROM; +https://pwhois.org/php.who)';const PWHOIS_BULK_MAX = 500; // addresses per round trip; the server accepts more, this keeps answers promptconst PWHOIS_CONNECT_SECS = 10; // connect timeoutconst PWHOIS_READ_SECS = 20; // overall read deadline per round tripconst PWHOIS_MAX_BYTES = 4 << 20; // 4 MB answer cap per round trip/** * Look up one IPv4 or IPv6 address. * * Returns the record as [field => value], or null when the address is invalid, the * server could not be reached, or it had nothing to say (unrouted, unregistered space). * Values the server marks NULL are returned as PHP null. $options: 'app' (string shown * in the server's statistics), 'type' ('pwhois' default, or 'all' for the registry * record too), 'server', 'port'. */function pwhois_lookup(string $address, array $options = []): ?array{ $ip = pwhois_normalize_ip($address); if ($ip === null) return null; $type = ($options['type'] ?? 'pwhois') === 'all' ? 'all' : 'pwhois'; $hit = pwhois_cache_get($ip, $type); if ($hit !== false) return $hit; $raw = pwhois_transact(pwhois_request_header($options) . ($type === 'all' ? 'type=all ' : '') . $ip . "\n", $options); if ($raw === null) return null; pwhois_usage_count(1); $records = pwhois_parse($raw); $rec = ($records && pwhois_has_content($records[0])) ? $records[0] : null; pwhois_cache_put($ip, $type, $rec); return $rec;}/** True when the server knows the address: it is routed or registered. Unrouted, unregistered space comes back as all NULL (plus zero coordinates). */function pwhois_has_content(array $rec): bool{ foreach (['origin-as', 'prefix', 'org-name', 'net-name', 'as-org-name', 'org-id', 'net-range'] as $k) if (($rec[$k] ?? null) !== null) return true; return false;}/** * Look up many addresses in bulk mode. Duplicates are collapsed, invalid entries are * reported as null, and the result is keyed by the address exactly as you passed it. * * Returns [address => record|null]. Returns null only when no round trip could be * completed at all (connection failure); a partial failure leaves the missing * addresses null and sets $options['errors'] (by reference) with a message per batch. */function pwhois_lookup_bulk(array $addresses, ?array &$options = null): ?array{ $options = $options ?? []; $result = []; $byIp = []; $order = []; foreach ($addresses as $a) { if (!is_string($a) && !is_int($a)) continue; $key = (string) $a; $ip = pwhois_normalize_ip($key); $result[$key] = null; if ($ip === null) continue; if (!isset($byIp[$ip])) { $byIp[$ip] = []; $order[] = $ip; } $byIp[$ip][] = $key; } if (!$order) return $result; // Serve what the cache already knows; only the rest goes to the server. $pending = []; foreach ($order as $ip) { $hit = pwhois_cache_get($ip, 'pwhois'); if ($hit !== false) { foreach ($byIp[$ip] as $key) $result[$key] = $hit; } else $pending[] = $ip; } $options['cached'] = count($order) - count($pending); $errors = []; $completed = 0; if (!$pending) { $options['errors'] = []; return $result; } foreach (array_chunk($pending, (int) ($options['batch'] ?? PWHOIS_BULK_MAX)) as $n => $chunk) { $raw = pwhois_transact(pwhois_request_header($options) . "begin\n" . implode("\n", $chunk) . "\nend\n", $options); if ($raw === null) { $errors[] = 'batch ' . ($n + 1) . ': no answer from the server'; continue; } $completed++; pwhois_usage_count(count($chunk)); $seen = []; foreach (pwhois_parse($raw) as $rec) { $ip = pwhois_normalize_ip((string) ($rec['ip'] ?? '')); if ($ip === null || !isset($byIp[$ip])) continue; $val = pwhois_has_content($rec) ? $rec : null; $seen[$ip] = true; foreach ($byIp[$ip] as $key) $result[$key] = $val; pwhois_cache_put($ip, 'pwhois', $val); } foreach ($chunk as $ip) if (!isset($seen[$ip])) pwhois_cache_put($ip, 'pwhois', null); // the server said nothing: unrouted } $options['errors'] = $errors; return $completed === 0 ? null : $result;}/** Canonical text of a valid IPv4/IPv6 address, or null. A trailing /bits is dropped. */function pwhois_normalize_ip(string $s): ?string{ $s = trim($s); if ($s === '') return null; $s = (string) preg_replace('~/\d{1,3}$~', '', $s); if (!filter_var($s, FILTER_VALIDATE_IP)) return null; $bin = @inet_pton($s); return $bin === false ? null : (string) inet_ntop($bin);}/** The app= line that identifies this client in the server's statistics. */function pwhois_request_header(array $options): string{ $app = (string) ($options['app'] ?? PWHOIS_APP); $app = (string) preg_replace('/[^\x20-\x7e]/', '', str_replace('"', '', $app)); return 'app="' . substr($app, 0, 60) . "\"\n";}/** * One connection, one request, the whole answer. Returns the raw text or null. * The server is reached over IPv4 on purpose: the public service authorizes IPv4 * sources for bulk mode, and a hostname that also has an AAAA record would otherwise * be tried over IPv6 first. */function pwhois_transact(string $request, array $options = []): ?string{ $host = (string) ($options['server'] ?? PWHOIS_SERVER); $port = (int) ($options['port'] ?? PWHOIS_PORT); $addr = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host); if (!filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return null; $sock = @stream_socket_client('tcp://' . $addr . ':' . $port, $errno, $errstr, PWHOIS_CONNECT_SECS); if (!$sock) return null; stream_set_timeout($sock, PWHOIS_READ_SECS); if (@fwrite($sock, $request) === false) { fclose($sock); return null; } $buf = ''; $deadline = microtime(true) + PWHOIS_READ_SECS; while (!feof($sock) && microtime(true) < $deadline) { $chunk = fread($sock, 65536); if ($chunk === false) break; if ($chunk === '') { $meta = stream_get_meta_data($sock); if (!empty($meta['timed_out'])) break; continue; } $buf .= $chunk; if (strlen($buf) > PWHOIS_MAX_BYTES) break; } fclose($sock); return $buf === '' ? null : $buf;}/** * Parse the server's "Field: value" text into records. Records are separated by blank * lines or by a new "IP:" line. Only the first colon splits a line, so IPv6 values * survive. Field names are lower-cased with their hyphens kept ("origin-as"). */function pwhois_parse(string $raw): array{ $records = []; $cur = []; foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) { if (trim($line) === '') { if ($cur) { $records[] = $cur; $cur = []; } continue; } if (stripos($line, 'Error:') === 0) continue; $p = strpos($line, ':'); if ($p === false) continue; $key = strtolower(trim(substr($line, 0, $p))); if ($key === '' || !preg_match('/^[a-z0-9 _-]+$/', $key)) continue; $key = str_replace(' ', '-', $key); if ($key === 'ip' && $cur) { $records[] = $cur; $cur = []; } $cur[$key] = pwhois_clean_value(substr($line, $p + 1)); } if ($cur) $records[] = $cur; return $records;}/** Trim, map the server's NULL sentinel to null, and drop anything that is not printable text. */function pwhois_clean_value(string $v): ?string{ $v = trim($v); if ($v === '' || strcasecmp($v, 'NULL') === 0) return null; $v = (string) preg_replace('/[^\P{C}]/u', '', $v); // Registry text is occasionally polluted (e.g. a pasted token after an org name); keep the name, drop the blob. $v = (string) preg_split('/\s*-{2,}\s*BEGIN\b/i', $v, 2)[0]; $v = (string) preg_replace('/\b[0-9a-fA-F]{32,}\b/', '', $v); $v = trim((string) preg_replace('/\s{2,}/', ' ', $v)); return $v === '' ? null : $v;}/* --------------------------------------------------------------------------------- Optional 24-hour cache. Off until you enable it. Backend: SQLite through PDO (part of PHP itself, nothing to install). Every process and application sharing the file gets real row-level upserts and SQLite's own locking, so concurrent writers are safe; expiry and least-recently-used eviction are indexed columns. Where pdo_sqlite is unavailable, a directory of one small file per address is used instead (atomic renames, no shared write contention). --------------------------------------------------------------------------------- *//** * Enable the cache. $path defaults to <system temp dir>/pwhois-cache.sqlite; give a * directory (existing, or ending in "/") to force the file-per-address backend. $ttl is * the lifetime of an answer in seconds (a day); $max the number of addresses kept. * Negative answers (unrouted space) are cached too, so they are not asked again either. */function pwhois_cache_enable(?string $path = null, int $ttl = 86400, int $max = 20000): void{ $c = &pwhois_state(); $path = $path ?? rtrim(sys_get_temp_dir(), '/') . '/pwhois-cache.sqlite'; $useDir = is_dir($path) || substr($path, -1) === '/' || !class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true); if (!$useDir && !is_dir(dirname($path))) @mkdir(dirname($path), 0700, true); $c['cache'] = ['mode' => $useDir ? 'dir' : 'sqlite', 'path' => rtrim($path, '/'), 'ttl' => max(60, $ttl), 'max' => max(100, $max), 'pdo' => null, 'hits' => 0, 'misses' => 0, 'writes' => 0]; if ($useDir && !is_dir($c['cache']['path'])) @mkdir($c['cache']['path'], 0700, true);}function pwhois_cache_disable(): void { $c = &pwhois_state(); $c['cache'] = null; }/** Hits, misses, writes and entries so far; null when the cache is off. */function pwhois_cache_stats(): ?array{ $c = &pwhois_state(); if (!$c['cache']) return null; $n = 0; if ($c['cache']['mode'] === 'sqlite') { $pdo = pwhois_cache_pdo(); if ($pdo) { try { $n = (int) $pdo->query('SELECT COUNT(*) FROM pwhois_cache')->fetchColumn(); } catch (Throwable $e) { } } } else { foreach (new DirectoryIterator($c['cache']['path']) as $f) if ($f->isFile() && substr($f->getFilename(), -5) === '.json') $n++; } return ['backend' => $c['cache']['mode'], 'path' => $c['cache']['path'], 'ttl' => $c['cache']['ttl'], 'entries' => $n, 'hits' => $c['cache']['hits'], 'misses' => $c['cache']['misses'], 'writes' => $c['cache']['writes']];}/** Remove expired rows and, if over $max, the least recently used. Runs automatically about once per 500 writes. */function pwhois_cache_prune(): void{ $c = &pwhois_state(); if (!$c['cache']) return; $now = time(); $cut = $now - $c['cache']['ttl']; if ($c['cache']['mode'] === 'sqlite') { $pdo = pwhois_cache_pdo(); if (!$pdo) return; try { $pdo->prepare('DELETE FROM pwhois_cache WHERE stored < :cut')->execute([':cut' => $cut]); $n = (int) $pdo->query('SELECT COUNT(*) FROM pwhois_cache')->fetchColumn(); if ($n > $c['cache']['max']) { $st = $pdo->prepare('DELETE FROM pwhois_cache WHERE k IN (SELECT k FROM pwhois_cache ORDER BY used ASC LIMIT :n)'); $st->bindValue(':n', $n - $c['cache']['max'], PDO::PARAM_INT); $st->execute(); } } catch (Throwable $e) { } } else { $files = []; foreach (new DirectoryIterator($c['cache']['path']) as $f) { if (!$f->isFile() || substr($f->getFilename(), -5) !== '.json') continue; $m = $f->getMTime(); if ($m < $cut) @unlink($f->getPathname()); else $files[$f->getPathname()] = $m; } if (count($files) > $c['cache']['max']) { asort($files); foreach (array_slice(array_keys($files), 0, count($files) - $c['cache']['max']) as $fp) @unlink($fp); } }}/** @internal shared state */function &pwhois_state(): array{ static $state = ['cache' => null, 'usage' => null]; return $state;}/** @internal open (and on first use create) the SQLite cache */function pwhois_cache_pdo(): ?PDO{ $c = &pwhois_state(); if (!$c['cache'] || $c['cache']['mode'] !== 'sqlite') return null; if ($c['cache']['pdo'] instanceof PDO) return $c['cache']['pdo']; try { $pdo = new PDO('sqlite:' . $c['cache']['path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]); $pdo->exec('PRAGMA journal_mode=WAL'); $pdo->exec('PRAGMA synchronous=NORMAL'); $pdo->exec('PRAGMA busy_timeout=5000'); $pdo->exec('CREATE TABLE IF NOT EXISTS pwhois_cache (k TEXT PRIMARY KEY, stored INTEGER NOT NULL, used INTEGER NOT NULL, rec TEXT)'); $pdo->exec('CREATE INDEX IF NOT EXISTS pwhois_cache_used ON pwhois_cache(used)'); $pdo->exec('CREATE INDEX IF NOT EXISTS pwhois_cache_stored ON pwhois_cache(stored)'); return $c['cache']['pdo'] = $pdo; } catch (Throwable $e) { $c['cache']['mode'] = 'dir'; $c['cache']['path'] .= '.d'; @mkdir($c['cache']['path'], 0700, true); return null; } // fall back to files}/** @internal file name for an entry in directory mode */function pwhois_cache_file(string $k): string { $c = &pwhois_state(); return $c['cache']['path'] . '/' . hash('sha256', $k) . '.json'; }/** Returns the cached record (array or null) or false when absent/expired. */function pwhois_cache_get(string $ip, string $type){ $c = &pwhois_state(); if (!$c['cache']) return false; $k = $type . ':' . $ip; $now = time(); $cut = $now - $c['cache']['ttl']; if ($c['cache']['mode'] === 'sqlite' && ($pdo = pwhois_cache_pdo())) { try { $st = $pdo->prepare('SELECT stored, rec FROM pwhois_cache WHERE k = :k'); $st->execute([':k' => $k]); $row = $st->fetch(PDO::FETCH_ASSOC); if (!$row || (int) $row['stored'] < $cut) { $c['cache']['misses']++; return false; } if ($now - (int) ($row['used'] ?? 0) > 60) $pdo->prepare('UPDATE pwhois_cache SET used = :u WHERE k = :k')->execute([':u' => $now, ':k' => $k]); $c['cache']['hits']++; $rec = json_decode((string) $row['rec'], true); return is_array($rec) ? $rec : null; } catch (Throwable $e) { $c['cache']['misses']++; return false; } } $f = pwhois_cache_file($k); if (!is_file($f) || (int) @filemtime($f) < $cut) { $c['cache']['misses']++; return false; } $j = json_decode((string) @file_get_contents($f), true); if (!is_array($j) || !array_key_exists('r', $j)) { $c['cache']['misses']++; return false; } @touch($f); $c['cache']['hits']++; return $j['r'];}function pwhois_cache_put(string $ip, string $type, ?array $rec): void{ $c = &pwhois_state(); if (!$c['cache']) return; $k = $type . ':' . $ip; $now = time(); $c['cache']['writes']++; if ($c['cache']['mode'] === 'sqlite' && ($pdo = pwhois_cache_pdo())) { try { $pdo->prepare('INSERT INTO pwhois_cache (k, stored, used, rec) VALUES (:k, :t, :t, :r) ON CONFLICT(k) DO UPDATE SET stored = :t, used = :t, rec = :r')->execute([':k' => $k, ':t' => $now, ':r' => json_encode($rec)]); } catch (Throwable $e) { } } else { $f = pwhois_cache_file($k); $tmp = $f . '.' . getmypid() . '.tmp'; if (@file_put_contents($tmp, json_encode(['r' => $rec])) !== false) @rename($tmp, $f); else @unlink($tmp); } if ($c['cache']['writes'] % 500 === 0) pwhois_cache_prune();}/* --------------------------------------------------------------------------------- Allowance: what this process has spent today, and (from the service) what the address you are querying from has left. --------------------------------------------------------------------------------- *//** Queries this process has sent today (UTC), counted locally; cache hits are free. */function pwhois_usage(): int{ $c = &pwhois_state(); $day = gmdate('Y-m-d'); if (!is_array($c['usage']) || $c['usage']['day'] !== $day) $c['usage'] = ['day' => $day, 'n' => 0]; return (int) $c['usage']['n'];}function pwhois_usage_count(int $n): void { pwhois_usage(); $c = &pwhois_state(); $c['usage']['n'] += $n; }/** * Your daily allowance as the service sees it, for the address this call is made from: * ['address', 'limit', 'used', 'remaining', 'resets_at' (ISO 8601 UTC)]. Free; it is not a * whois query. Returns null when the service cannot be reached. */function pwhois_quota(array $options = []): ?array{ $url = (string) ($options['quota_url'] ?? 'https://pwhois.org/tools/quota.php'); $ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => 10, 'header' => "User-Agent: " . PWHOIS_USER_AGENT . "\r\nAccept: application/json\r\n"]]); $j = @file_get_contents($url, false, $ctx); if ($j === false) return null; $d = json_decode($j, true); return (is_array($d) && isset($d['limit'])) ? $d : null;}/* --------------------------------------------------------------------------------- Backward compatibility with the 2005 interface (doPWLookup / doPWLookupBulk). Those returned keys with the hyphens removed ("originas") and 0 on failure; keep that shape for existing callers. New code should use the pwhois_* functions. --------------------------------------------------------------------------------- */if (!function_exists('doPWLookup')) { function doPWLookup($query) { $r = pwhois_lookup((string) $query, ['app' => 'PWPHPLib']); if ($r === null) return 0; $out = []; foreach ($r as $k => $v) $out[str_replace('-', '', $k)] = (string) $v; return $out; }}if (!function_exists('doPWLookupBulk')) { function doPWLookupBulk($queryarray) { $opts = ['app' => 'PWPHPLib']; $r = pwhois_lookup_bulk(is_array($queryarray) ? $queryarray : [], $opts); if ($r === null) return 0; $out = []; foreach ($r as $ip => $rec) { if ($rec === null) continue; $flat = []; foreach ($rec as $k => $v) $flat[str_replace('-', '', $k)] = (string) $v; $out[$ip] = $flat; } return $out; }}
5,000 a day, shared by address
Every query counts against the daily allowance of the address it comes from. The library collapses duplicates and, with the cache on, never asks twice; pwhois_quota() shows the balance. Higher limits are customarily granted free upon reasonable request: ask.
C, and everything else
The C library behind LFT and WhoB does the same over port 43; any language with sockets can speak the plain-text protocol directly, and AI assistants can use the MCP connector.
Open source
Released under the same license as the Prefix WhoIs software. Take it, ship it, tell us what you built.