rlanvin/php-ip
PHP library for working with IPv4 and IPv6 addresses as objects, with subnet and range support. Parse, validate, and manipulate addresses, convert between formats, and calculate networks and masks through a clean, well-tested API.
Install and verify. No service provider, no config file, no facade — it's a plain library that autoloads under IPLib\:
composer require rlanvin/php-ip
php artisan tinker
>>> $range = IPLib\Factory::range('10.0.0.0/8');
>>> $range->contains(IPLib\Factory::address('10.42.7.9'));
=> true
>>> (string) $range;
=> "10.0.0.0/8"
If that works, you're done with setup. Check ext-gmp is enabled (php -m | grep gmp) — the library uses it for IPv6/bignum arithmetic and falls back to a slower path without it.
Where to look first. Four entry points cover ~95% of real usage:
| Symbol | Use |
|---|---|
IPLib\Factory |
Parsing entry point: Factory::address(), Factory::range(), Factory::rangeFromBoundaries() |
IPLib\Address\{IPv4,IPv6} |
Single addresses; ->toInteger(), ->toString(), ->getBytes() |
IPLib\Range\{Single,Subnet,Pattern} |
A single IP, a CIDR block, or 192.168.*.*-style patterns — all implement RangeInterface |
IPLib\Range\Type |
T_PRIVATE, T_LOOPBACK, T_LINKLOCAL, T_GLOBAL, … via $range->getRangeType() |
Version note: 1.x releases used flatter names (
IPLib\IPv4,IPLib\IPv4Block); 2.x uses theAddress\*/Range\*namespaces above. Runcomposer show rlanvin/php-ipbefore copying examples from the README, and pin the major version incomposer.json.
First use case: a CIDR allowlist. Almost every Laravel app eventually needs "is this request IP inside one of these blocks?" — admin networks, partner webhooks, health-check probes. Register the parsed list once in a container singleton and the whole thing costs one parse per request:
// app/Support/Networks/TrustedNetworks.php
final class TrustedNetworks
{
/** @var \IPLib\Range\RangeInterface[] */
private array $ranges;
public function __construct(array $cidrs)
{
$this->ranges = array_values(array_filter(
array_map(fn ($cidr) => Factory::range(trim($cidr)), $cidrs)
));
}
public function contains(?string $ip): bool
{
$address = $ip === null ? null : Factory::address($ip);
if ($address === null) {
return false;
}
foreach ($this->ranges as $range) {
if ($range->contains($address)) {
return true;
}
}
return false;
}
}
// app/Providers/AppServiceProvider.php
$this->app->singleton(
TrustedNetworks::class,
fn () => new TrustedNetworks(config('networks.trusted', []))
);
// config/networks.trusted => ['10.0.0.0/8', '203.0.113.0/24', '2001:db8::/32']
Parse at the boundary, pass objects inward. The library's value objects are cheap to compare and safe to instanceof-check; strings are not (2001:db8::1 and 2001:0db8:0000::0001 are the same address). Normalize user input to ->toString() the moment it enters your app, whether that's a FormRequest, a cast, or a console command.
Custom validation rules. Laravel's ip, ipv4, ipv6 rules reject CIDR notation outright, so a Cidr rule is the most common first write:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use IPLib\Factory;
class Cidr implements ValidationRule
{
public function __construct(private bool $requirePrefix = true) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail('The :attribute must be an IP address or CIDR range.');
return;
}
$range = Factory::range(trim($value));
if ($range === null) {
$fail('The :attribute is not a valid IPv4/IPv6 range.');
return;
}
if ($this->requirePrefix && ! str_contains($value, '/')) {
$fail('The :attribute must include a prefix length, e.g. 10.0.0.0/8.');
}
}
}
Pair it with a second rule that checks overlap, so you can't add 10.0.0.0/8 twice under a different label:
// overlap test: ranges intersect when either endpoint of one falls inside the other
$overlaps = $candidate->contains($existing->getStartAddress())
|| $existing->contains($candidate->getStartAddress());
Eloquent cast for canonical storage. Store the string form, hydrate the object:
class IpRangeCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): ?RangeInterface
{
return $value === null ? null : Factory::range($value);
}
public function set($model, $key, $value, $attributes): ?string
{
$range = $value instanceof RangeInterface
? $value
: (is_string($value) ? Factory::range(trim($value)) : null);
return $range?->toString(); // canonical: 192.168.1.0/24, not 192.168.1.5/24
}
}
Push membership checks into SQL when the table is large. Compute bounds once in PHP, store ip_from / ip_to as unsignedBigInteger (IPv4) or binary(16) (IPv6), and query with an index instead of iterating rows:
$range = Factory::range($request->string('cidr'));
$matches = IpAllocation::query()
->where('ip_from', '<=', $range->getEndAddress()->toInteger())
->where('ip_to', '>=', $range->getStartAddress()->toInteger())
->get();
On PostgreSQL, prefer native inet / cidr columns and let the DB do it (WHERE ip <<= ?::cidr); the library still earns its keep validating and canonicalizing before the binding is sent.
Middleware for allow/deny lists. Resolve the singleton, call contains($request->ip()), and make the failure mode explicit (abort(403) vs. log-and-allow for monitoring endpoints). Always use $request->ip() rather than $_SERVER['REMOTE_ADDR'] so Laravel's TrustProxies middleware has already resolved the real client address.
Scheduled refresh of published provider ranges. AWS, GCP and Cloudflare all publish CIDR JSON/text files. A tiny artisan command keeps them current without hardcoding:
foreach (['https://www.cloudflare.com/ips-v4', 'https://www.cloudflare.com/ips-v6'] as $url) {
foreach (preg_split('/\r?\n/', Http::get($url)->body()) as $line) {
if ($range = Factory::range(trim($line))) {
$ranges[] = $range->toString();
}
}
}
Cache::forever('net:cloudflare', $ranges);
Then bind new TrustedNetworks(Cache::get('net:cloudflare', [])) in a scoped container binding.
SSRF guard on user-supplied URLs. Before dispatching a webhook, resolve the host and reject anything that isn't global:
$address = Factory::address(gethostbyname($host));
if ($address === null || $address->getRangeType() !== Type::T_GLOBAL) {
throw ValidationException::withMessages(['url' => 'That host is not allowed.']);
}
Note the DNS-rebinding caveat: resolve and pin, or check at connection time.
Rate-limit buckets by subnet, not address. IPv6 clients rotate within a /64 freely, so key on the network prefix. Canonicalize with the library, slice with inet_pton (16-byte binary is unambiguous and avoids prefix-alignment questions):
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)
->by($this->bucket($request->ip())));
private function bucket(?string $ip): string
{
$canonical = $ip ? Factory::address($ip)?->toString() : null;
if ($canonical === null) {
return 'unknown';
}
$binary = inet_pton($canonical);
return bin2hex(substr($binary, 0, strlen($binary) === 4 ? 3 : 8)); // /24 or /64
}
Parsing returns null, not exceptions. Factory::range() / Factory::address() fail quietly — ->contains() on null is a fatal error. Always null-check, and keep parsing inside your validation rules so the rest of the app can assume valid objects. Some constructors (Subnet::fromString() etc.) do throw on malformed input; wrap those in try/catch if you use them directly instead of the factory.
Host bits are a trap. 192.168.1.5/24 is ambiguous: some paths normalize to 192.168.1.0/24, others reject it. Decide your policy (I normalize via ->toString() on write and validate on read) and write a test for it — this is exactly the kind of thing that silently changes between majors.
Never iterate an IPv6 range. getSize() on a /64 is 2^64. If you expose "list the addresses in this block", cap the prefix length first (>= /120 for v6, >= /22 for v4) and reject with a validation message otherwise.
IPv4 and IPv6 don't mix. contains() will not match ::ffff:192.168.0.1 against 192.168.0.0/16. Normalize families at the edge: force X-Forwarded-For chains through $request->ip(), and when storing allowlists, store v4 and v6 entries as the same row type but never assume one covers the other.
Install ext-gmp. Without it the library falls back to slower math and large-range operations (bounds, sizes, sorting long lists) get noticeably slow. Add "ext-gmp": "*" to require if you do heavy range work in a request path.
Cap the parse, not the loop. For a config file with hundreds of CIDRs, parsing per-request per-check adds up. Parse once into a singleton, Cache::rememberForever the canonical strings, and order the list by prefix length descending (most specific first) if you need first-match semantics.
Don't cast ranges to JSON objects. RangeInterface isn't a string. Implement JsonSerializable (or ->toString() in your API Resource) or you'll ship {"prefix":24,...} to a frontend expecting "10.0.0.0/8". Same rule for @dd()-style debugging: (string) $range is what you want.
SQLite has no IP functions. Anything you'd write as INET_ATON() for MySQL won't run in your test suite. Keep the comparison in PHP (contains()) for tests and use the bounds-integer approach only where you've confirmed the production driver — or use binary(16) columns so behaviour is identical everywhere.
toInteger() on IPv6 is not a PHP int. On 32-bit builds it can come back as a numeric string, and even on 64-bit it exceeds PHP_INT_MAX past /64. Store IPv6 as binary(16) or the canonical string — never as an integer column.
Pin the major version and isolate it. Last release was March 2022; the library is effectively feature-complete and stable, but you won't get security patches. Wrap it in one small app-level service (App\Support\Networks\*) so a future swap to Symfony's IpUtils (well-maintained, same CIDR-membership niche) is a single-file change rather than a grep across the codebase.
Don't reach for it when filter_var will do. A single "is this a valid IP" check is faster and simpler as Laravel's built-in ip / ipv4 / ipv6 rules. This package earns its place only once you need range semantics: containment, overlap, canonicalization, bounds.
How can I help you explore Laravel packages today?