Search by

vpndetection / symfony

mslm

Official Symfony bundle for the VPNDetection API. Detect VPNs, proxies, Tor, hosting and CDN visitors on every request.

v1.0.0 2026-09-13 07:19 UTC

This package is auto-updated.

Last update: 2026-09-13 08:32:25 UTC


README

Packagist license

The official Symfony Bundle for the VPNDetection API.

It classifies the visitor behind each request — VPN, residential proxy, Tor, hosting, CDN, relay — and hands the answer to your code. Blocking is opt-in.

Getting Started

composer require vpndetection/symfony

Requires PHP 8.1 or newer.

You need an API key. Create one in the console; the free tier's allowance is counted per source address, and a server is a single source address, so a key is what makes this usable in production rather than optional.

Register the bundle and configure it:

// config/bundles.php
VPNDetection\Symfony\VPNDetectionBundle::class => ["all" => true],
# config/packages/vpndetection.yaml
vpndetection:
    api_key: "%env(VPNDETECTION_API_KEY)%"
use VPNDetection\Symfony\VPNDetectionListener;

#[Route("/")]
public function index(Request $request): Response
{
    $lookup = VPNDetectionListener::lookup($request);
    return new Response($lookup?->result?->isVpn ? "Hello, VPN user" : "Hello");
}

The listener runs just before Symfony's router, so a blocked request costs no route match, and sub-requests are not classified again — every forward() and ESI fragment is the same visitor.

By default nothing is blocked. Every request gets an answer and your own code decides what that means — which is usually what you want, because whether a VPN visitor is a problem depends entirely on what they are doing.

Blocking

Set a block_condition and a matching request is answered with 403 and never reaches your code.

'block_condition' => ['isVpn' => true],

A condition is written in the shape of a Result, and only the members you name are considered. That lets it reach the evidence, not just the flags:

['isVpn' => true, 'vpn' => ['provider' => 'nordvpn']]        // one provider
['isResproxy' => true, 'resproxy' => ['hits' => ['gte' => 5]]]  // a numeric threshold
['vpn' => ['confidence' => ['high', 'medium']]]              // any of these
[['isTor' => true], ['isResproxy' => true]]                  // a list is OR

Values are matched by equality, strings without regard to case. A LIST means any-of. A map of gte/gt/lte/lt compares numbers, and every bound you give must hold, so two of them are a range. Members set to false or null are ignored, so a condition states the signals you act on; one that constrains nothing would match every request, and is refused when the middleware is built rather than silently blocking all your traffic.

Where the client address comes from

This is the setting that decides whether any of the above works, and it is the one thing only you can get right.

By default the listener uses $request->getClientIp(), which honours Symfony's trusted-proxy configuration. That is the right fix behind a load balancer: set framework.trusted_proxies to the proxies actually in front of you and Symfony walks the chain for you. Without it, and behind one, every visitor looks like the load balancer — a datacenter address, so a hosting rule would block all of them.

For an edge that writes the address into its own header, name the header:

# config/packages/vpndetection.yaml
vpndetection:
    client_ip_header: CF-Connecting-IP

A callable cannot live in YAML, so a header NAME is the config key. Anything more exotic — a chain depth, your own logic — means replacing the vpndetection.listener service, which is Symfony's own answer rather than a config key we would have to keep inventing.

\VPNDetection\Symfony\Selectors::xff() reads X-Forwarded-For directly, and Selectors::xff(1) counts one trusted hop from the right. Be aware that the left-most entry is whatever the caller sent, because proxies append to that header — it is only trustworthy when an edge you control overwrites it. Both are for a replaced listener service; prefer framework.trusted_proxies.

If the address resolves to a private one, the middleware says so once. That is expected locally and is the signal to fix your configuration anywhere else.

When a lookup fails

The request is let through, and the reason is on the answer's error. Our outage should not become yours, so a network failure, an exhausted quota or a rejected key all fail open. Set fail_closed to block instead. Private addresses are answered locally and never fail, so this will not lock you out in development.

Cost and latency

Answers are cached for an hour, so a returning visitor costs nothing, and private addresses never leave the process. A cache miss is one request to our API, bounded at 2.5 seconds by default and not retried — on a request path, failing open quickly beats holding a visitor while we try again.

Beyond a few million distinct visitors a day, stop calling the API per request: download the dataset and look addresses up locally instead.

Absent is not false

Only ip and isVpn come back on every plan. A property your plan does not include is null, which means "not in your plan" rather than "checked, and no".

$lookup->result->isHosting ?? false   // when you only want the flag

A block_condition naming a member your plan does not serve can never match, so the middleware warns once instead of failing silently. Set on_missing_field to throw to make it an error.

Other Libraries

There are official VPNDetection client libraries available for many languages including PHP, Python, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. See our GitHub at https://github.com/vpndetection-io for more.

About VPNDetection

VPN Detection API: Accurate anonymity detection identifying VPNs, residential proxies, hosting servers, Tor nodes, CDNs, relays and more.

VPNDetection

License

This project is licensed under the MIT License.