mpge/php-country-block

Block or allow web traffic by country, with pluggable IP geolocation providers.

Maintainers

Package info

github.com/mpge/PHP-Country-Block

pkg:composer/mpge/php-country-block

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 9

Open Issues: 0

v2.0.0 2026-08-09 16:54 UTC

This package is auto-updated.

Last update: 2026-08-10 00:51:32 UTC


README

CI PHP License

Block or allow web traffic by country, with pluggable IP geolocation providers.

$blocker = CountryBlocker::block(['CA', 'US', 'CG'])
    ->using(new Ip2LocationResolver($apiKey));

if ($blocker->check()->blocked) {
    http_response_code(403);
    exit;
}

Version 2, twelve years later

Version 1 shipped in 2014 and was built on ipinfodb.com, whose IP location API is no longer something you can point a library at. That would have been reason enough for a new release. Reading the old code back was reason for a rewrite.

What version 1 did, and what version 2 does instead:

Version 1 Version 2
One API call per blocked country: five countries meant five lookups One lookup per request, then a set membership check
Scraped a raw text response with a regular expression Typed JSON handling per provider
The constructor made HTTP calls, set a cookie, and wrote to $_COOKIE The constructor wires dependencies; check() returns a value object
An ip_not_allowed cookie anyone could forge, which blocked forever once set A PSR-16 cache of the country code; the verdict is recomputed every request
Trusted HTTP_CLIENT_IP and X-Forwarded-For unconditionally REMOTE_ADDR only, unless you name your proxies
Failed open silently when the API errored An explicit FailureMode, plus an error callback you can log
Plain http:// HTTPS everywhere; the one provider that cannot do HTTPS free must be opted into by name
One provider, hard-wired Six, in a fallback chain, behind one interface

Version 1 is still installable. It is tagged v1.0.0, and a countryBlock shim keeps old call sites running under version 2. See UPGRADING.md.

Requirements

PHP 8.2 or newer, with ext-curl and ext-json. The only required package is psr/simple-cache, and that is interfaces only.

Install

composer require mpge/php-country-block

Quick start

<?php

require __DIR__ . '/vendor/autoload.php';

use Mpge\CountryBlock\CountryBlocker;
use Mpge\CountryBlock\Resolver\Ip2LocationResolver;

// IP2Location.io works without a key at 1,000 lookups a day.
$blocker = CountryBlocker::block(['CA', 'US', 'CG'])
    ->using(new Ip2LocationResolver());

$decision = $blocker->check();

if ($decision->blocked) {
    http_response_code(403);
    echo 'Not available in your country.';
    exit;
}

check() takes an optional address. Pass one to check somebody other than the current visitor; leave it off and the client address is worked out for you.

Allowlist instead

Most geo-fencing is really an allowlist. A country you forgot to think about should fail closed, not open:

$blocker = CountryBlocker::allow(['CA'])
    ->using(new Ip2LocationResolver($apiKey))
    ->onFailure(FailureMode::Block);

Providers

Every provider implements one interface:

interface CountryResolver
{
    public function resolve(string $ip): ?Lookup;
}
Resolver Key Free tier Transport Worth knowing
CloudflareHeaderResolver none unlimited no request at all Only if your origin is locked to Cloudflare
MaxMindResolver account for the download unlimited, local local file You own the update cycle
Ip2LocationResolver optional 1,000/day keyless, 50,000/month keyed HTTPS The direct replacement for version 1's provider
IpInfoResolver required Lite is uncapped HTTPS Country and ASN only on Lite, which is all this needs
IpApiCoResolver optional 1,000/day HTTPS Keyless and still encrypted, so a good last resort
IpApiComResolver optional 45/minute plain HTTP unless you pay See the warning below
StaticResolver none n/a none Forces a country. For local development and tests

About ip-api.com

Its free tier has no HTTPS. A network attacker between your server and theirs can rewrite the country in the response, and you would then admit exactly the traffic you set out to block. Because that is a real downgrade from every other option here, the constructor refuses to build until you say so out loud:

new IpApiComResolver(allowInsecureTransport: true);   // keyless, plaintext, eyes open
new IpApiComResolver('pro-key');                      // https://pro.ip-api.com, no opt-in needed

About the Cloudflare header

CF-IPCountry costs nothing and adds no latency, because Cloudflare resolved the country before your PHP process started. It is also just a header. If your origin server is reachable directly, anyone can set it. Restrict your origin to Cloudflare's IP ranges, or use authenticated origin pulls, before trusting it.

The resolver also declines to answer for any address other than the one the request came from, rather than confidently returning the wrong country.

MaxMind

composer require geoip2/geoip2
new MaxMindResolver('/var/lib/GeoIP/GeoLite2-Country.mmdb');

No network call, no rate limit, and no third party learning your visitors' addresses. Keep the database updated; a stale .mmdb gets quietly less accurate.

Chaining providers

Order them cheap to expensive. The first real answer wins, and a provider that is down is stepped over rather than allowed to end the request:

$blocker = CountryBlocker::block(['RU', 'KP'])
    ->using(
        new CloudflareHeaderResolver(),                              // free, already resolved
        new MaxMindResolver('/var/lib/GeoIP/GeoLite2-Country.mmdb'), // local disk
        new Ip2LocationResolver($apiKey),                            // paid, accurate
        new IpApiCoResolver(),                                       // keyless backstop
    );

Silent fallback is how an expired API key goes unnoticed for a year, so wire the error callback to your logger:

$blocker = $blocker->onResolverError(function (CountryResolver $resolver, ResolverException $e) use ($log) {
    $log->warning('Country lookup failed', ['resolver' => $resolver::class, 'error' => $e->getMessage()]);
});

Caching

Cache the country and the verdict stays live. That distinction is what version 1's cookie got wrong: it cached the answer, so changing your country list left old visitors on the old verdict.

$blocker = CountryBlocker::block(['CA'])
    ->using(new Ip2LocationResolver($apiKey))
    ->withCache(new FileCache('/tmp/country-block'), ttl: 86400, negativeTtl: 300);

Any PSR-16 pool works: symfony/cache, cache/redis-adapter, Laravel's Cache::store()->getStore(), whatever you already run. Two are bundled for projects that have none:

  • ArrayCache lives for one request, enough to stop a page that checks the same visitor three times from making three API calls.
  • FileCache persists to disk. Writes go via a temporary file and a rename, and reads unserialize with allowed_classes disabled.

Misses are cached too, on the shorter negativeTtl, so a run of unknown addresses cannot burn a day's quota in a minute.

Behind a proxy or load balancer

By default only REMOTE_ADDR is believed, so nobody can choose their own country by sending a header. That is deliberate, and it is the single biggest behavioural change from version 1.

When you really do sit behind a proxy, name it:

$blocker = $blocker->trustProxies(['10.0.0.0/8', '2001:db8::/32']);

Forwarding headers are then read only when the request actually arrived from one of those ranges. The chain is walked from the socket end inwards and the first hop that is not one of yours wins, so entries a client invented and prepended are never reached.

Reading a different header instead:

use Mpge\CountryBlock\Net\ClientIpResolver;

$blocker = $blocker->trustProxies(['10.0.0.0/8'], [ClientIpResolver::CLOUDFLARE]);

When nothing can resolve the country

Providers go down, rate-limit, and draw a blank on addresses they have never seen. Decide what that means for you:

->onFailure(FailureMode::Allow)   // default: the site stays up, some traffic gets through
->onFailure(FailureMode::Block)   // sanctions, licensing, regulatory fencing

Either way the Decision tells you it happened, so you can tell a policy verdict from a real match:

$decision = $blocker->check();

$decision->blocked;       // bool
$decision->allowed();     // bool
$decision->resolved();    // false when the verdict came from the FailureMode
$decision->countryCode;   // 'DE', or null
$decision->source;        // 'ip2location.io', 'cloudflare', ... or null
$decision->cached;        // came from the cache rather than a fresh lookup
$decision->ip;            // the address that was checked
$decision->reason;        // Reason::Listed | NotListed | Unresolved

A rise in Reason::Unresolved usually means a provider is failing, not that your traffic changed. It is worth a metric.

Using it in a framework

A front controller, the version 1 pattern brought forward:

// check.php, included at the top of your entry point
$decision = CountryBlocker::block(['CA', 'US', 'CG'])
    ->using(new Ip2LocationResolver($_ENV['IP2LOCATION_KEY']))
    ->withCache($psr16Cache)
    ->check();

if ($decision->blocked) {
    header('Location: /unavailable.php');
    exit;
}

Laravel middleware:

final class BlockCountries
{
    public function __construct(private readonly CountryBlocker $blocker) {}

    public function handle(Request $request, Closure $next): Response
    {
        if ($this->blocker->check($request->ip())->blocked) {
            abort(403, 'Not available in your country.');
        }

        return $next($request);
    }
}

Blockers are immutable, so one configured instance is safe to bind in a container and share.

See the examples directory for complete, runnable files.

Local development

StaticResolver saves you from the fact that no provider has anything useful to say about 127.0.0.1:

$resolver = app()->environment('local')
    ? new StaticResolver('DE')
    : new Ip2LocationResolver($apiKey);

Testing

composer install
composer test     # phpunit
composer stan     # phpstan, level 8

Nothing in the suite touches the network.

Upgrading from version 1

See UPGRADING.md. The short version: countryBlock still exists, still sets $isBlocked, and now emits a deprecation notice. It no longer sets the ip_not_allowed cookie, and it no longer uses $path_to_script.

License

MIT. See LICENSE.

The version 1 implementation bundled a copy of IP-User-Location by Tom Green, also MIT. Version 2 no longer includes it.