mpge / php-country-block
Block or allow web traffic by country, with pluggable IP geolocation providers.
Requires
- php: ^8.2
- ext-curl: *
- ext-json: *
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5 || ^12.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
Suggests
- geoip2/geoip2: Required by MaxMindResolver for offline GeoLite2/GeoIP2 database lookups (^3.0)
- psr/http-client: Required by Psr18Client to reuse an existing PSR-18 HTTP client
- psr/http-factory: Required by Psr18Client to build PSR-7 requests
This package is auto-updated.
Last update: 2026-08-10 00:51:32 UTC
README
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:
ArrayCachelives for one request, enough to stop a page that checks the same visitor three times from making three API calls.FileCachepersists to disk. Writes go via a temporary file and a rename, and reads unserialize withallowed_classesdisabled.
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.