VPNDetectionVPNDetection

PHP

The official PHP client library for the VPNDetection API.

See on GitHub

Getting Started

composer require vpndetection/vpndetection

Requires PHP 8.1 or newer. Everything is typed, and the tier-gated fields are nullable so an absent answer never reads as a false one.

Usage

No API key needed to start. The free tier answers ip and is_vpn, and allows 1000 requests per day per source address.

use VPNDetection\Client;

$client = new Client();

$result = $client->lookup('45.83.91.1');
echo $result->isVpn ? 'yes' : 'no';   // yes

With an API key

An API key raises your quota, and raises your features on a paid plan. Create one in the console, then pass it in:

use VPNDetection\Client;
use VPNDetection\Options;

$client = new Client(new Options(apiKey: getenv('VPNDETECTION_API_KEY')));

$result = $client->lookup('45.83.91.1');
$result->isVpn;             // true
$result->vpn->provider;     // 'mullvad'
$result->isHosting;         // true
$result->hosting->provider; // 'M247'

Batch lookup

You can do batch lookups with a list, which parallelizes requests for you efficiently:

use VPNDetection\VPNDetectionException;

$results = $client->lookupBatch(['45.83.91.1', '8.8.8.8', '1.1.1.1']);

foreach ($results as $ip => $result) {
    if ($result instanceof VPNDetectionException) {
        echo "{$ip}: {$result->getMessage()}\n";
        continue;
    }
    echo "{$ip}: ", $result->isVpn ? 'vpn' : 'clean', "\n";
}

Results are keyed by address, so duplicates in your list collapse into a single request and one address failing never loses the rest.

Concurrency and other variables are configurable per-call:

$results = $client->lookupBatch($manyIps, ['concurrency' => 32, 'retries' => 4]);

Caching

Answers are cached by default, so repeat lookups of the same address are free:

$client = new Client();

$result = $client->lookup('45.83.91.1');
$result->isVpn;    // true, API request

$result2 = $client->lookup('45.83.91.1');
$result2->isVpn;   // true, no API request, result was cached

You can change the default cache variables (max size, TTL in seconds, etc) on initialization, or even disable it:

$client = new Client(new Options(cacheMaxSize: 50_000, cacheTtlSeconds: 6 * 60 * 60));
$clientNoCache = new Client(new Options(cache: false));

The cache lives on the client instance, so how much it buys you depends on how long that instance lives. A worker, a queue consumer or a long-running server gets the full benefit; a classic one-request-per-process setup starts with an empty cache every time, and there a batch lookup is what saves you round trips.

Private and reserved addresses

Private, loopback, link-local, documentation and multicast addresses (and their IPv6 equivalents, including the 6to4 and Teredo ranges) can never be VPN or proxy infrastructure. The library answers them locally, so they cost no request and no quota:

$result = $client->lookup('192.168.1.1');
$result->isBogon;   // true, this answer was computed rather than served
$result->isVpn;     // false

The check is available on the client, which is handy when your inputs are addresses anyway:

$client->isBogon('10.0.0.1');   // true
$client->isBogon('8.8.8.8');    // false

It is also callable on its own, if you want it without a client:

use VPNDetection\Bogon;

Bogon::isBogon('10.0.0.1');   // true

Errors

Failures throw a VPNDetectionException carrying a kind and a isRetryable() flag:

use VPNDetection\VPNDetectionException;

try {
    $client->lookup('1.1.1.1');
} catch (VPNDetectionException $err) {
    echo $err->kind->value, ' ', $err->isRetryable() ? 'retryable' : 'final', "\n";
}

kind is one of bad_request, unauthorized, forbidden, rate_limited, quota_exceeded, server_error or network.

Note that rate_limited and quota_exceeded both arrive as HTTP 429 and are not the same thing. A rate limit is when the API faces extreme traffic bursts and so retrying later works; but a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, but not if your quota is exceeded.

Database downloads

If your key carries the db.download scope, the licensed databases are available through $client->database. download fetches one to a path, streaming it straight to disk so that nothing bigger than a chunk is ever held in memory:

$databases = $client->database->list();

$written = $client->database->download('vpn_ip_extended_v1', 'mmdb', '/srv/data/vpn_ip_extended_v1.mmdb');
echo "{$written} bytes";

Or take the time-limited link and run the transfer yourself, or take a small database as bytes:

$url = $client->database->downloadUrl('vpn_ip_extended_v1', 'mmdb');
$bytes = $client->database->downloadBytes('cdn_ip_v1', 'csvgz');

downloadBytes holds the whole file in memory, and the catalog runs from cdn_ip_v1 at 10 KB to resproxy_ip_90d_v1 at 1.79 GB, so use download for anything you have not measured: past your memory_limit this is a fatal error, not merely a slow one.

Absent is not false

A field your plan does not include is null, which is not the same answer as false: null means "not in your plan", false means "checked, and no".

$result->isHosting ?? false;   // when you only want the flag
$result->isHosting === null;   // not in your plan

On this page