s1lentium/iptools
A PHP library for working with IPv4 and IPv6 addresses, networks, and ranges. Parse, validate, and manipulate IPs; handle CIDR notation, subnet calculations, containment checks, iteration, and conversions. Useful for networking, access control, and address management tasks.
Install
composer require s1lentium/iptools
No service provider, facade, or config file ships with the package. Everything is plain PHP value objects under the S1lentium\IP namespace, so auto-discovery is irrelevant and you can use the classes directly in controllers, jobs, middleware, and Artisan commands.
Where to look first
README.md — the examples map almost 1:1 to the classes you'll use.src/IPv4.php / src/IPv6.php — the address value objects (parse, toLong, toBin, __toString).src/Network.php — CIDR math (getNetwork, getBroadcast, getFirstIP, getLastIP, getMask, getPrefixLength, getHosts, contains).src/Range.php — start/end ranges (parse, getFirstIP, getLastIP, contains, iterate).tests/ — the most reliable reference for exact method signatures since the README is thin in places.First use case
The fastest win in a Laravel app is IP allow-listing for admin/webhook routes. Create a small middleware:
use Closure;
use Illuminate\Http\Request;
use S1lentium\IP\Network;
class RestrictToAllowedCidrs
{
public function handle(Request $request, Closure $next)
{
$ip = $request->ip(); // respects TrustProxies
$allowed = config('firewall.allow', ['10.0.0.0/8', '192.168.0.0/16']);
foreach ($allowed as $cidr) {
if (Network::parse($cidr)->contains(IP::parse($ip))) {
return $next($request);
}
}
abort(403);
}
}
That's the whole integration surface — no bootstrapping, no container wiring. From there, the library earns its keep anywhere you're doing CIDR math, range checks, or subnet allocation.
Normalize user input before anything else. Requests carry IPv4, plain IPv6, and IPv4-mapped IPv6 (::ffff:10.0.0.1). Strip/normalize in a FormRequest or a small NormalizesIp trait so downstream code only ever sees one form.
$ip = IP::parse(trim($request->input('ip'))); // IPv4 or IPv6 factory
$canonical = (string) $ip; // compressed canonical form
Cast at the edges, value objects inside. Keep IPv4/IPv6/Network objects in jobs and services, but store strings in Eloquent. Don't try to persist the objects directly — there is no bundled cast.
// migration: keep the string for display, a numeric/binary column for indexing
$table->string('ip', 45)->index();
$table->unsignedBigInteger('ip_long')->nullable()->index(); // IPv4 only
$table->binary('ip_bin', 16)->nullable(); // IPv4 + IPv6, if you prefer
Store toLong() alongside IPv4 strings for cheap range queries. Sorting/filtering "IPs between X and Y" in the database is otherwise lexicographic and wrong.
$ip = IPv4::parse($request->ip());
User::whereBetween('ip_long', [IPv4::parse($start)->toLong(), IPv4::parse($end)->toLong()])->get();
For IPv6, sort on the 16-byte toBin() value in a BINARY(16) column; it compares correctly with raw >/</BETWEEN in MySQL and Postgres.
Use Network for CIDR checks, Range for explicit spans. Cloud providers (AWS/GCP/Cloudflare) publish mixed CIDR lists; Network::contains() is the natural fit. Ban lists scraped from logs are usually start-end ranges; use Range::parse('a-b').
$cloudCidrs = collect($json['prefixes'])->pluck('ip_prefix');
$isCloudIp = $cloudCidrs->contains(fn ($cidr) => Network::parse($cidr)->contains($ip));
Rate limiting by subnet, not IP. Rotating IPs inside a /24 defeat per-IP limiters; key the limiter on the network address.
RateLimiter::for('api-by-subnet', function (Request $request) {
$net = Network::parse($request->ip() . '/24')->getNetwork();
return Limit::perMinute(60)->by((string) $net);
});
Wrap parsing in a validation rule. filter_var($value, FILTER_VALIDATE_IP) validates bare addresses but not CIDRs.
class CidrRule implements \Illuminate\Contracts\Validation\Rule
{
public function passes($attribute, $value)
{
try { Network::parse($value); return true; }
catch (\Throwable) { return false; }
}
}
Iterate lazily for large blocks. Range::iterate() is a generator — pipe it through LazyCollection rather than materializing arrays.
foreach (Range::parse('10.0.0.1-10.0.255.254')->iterate() as $ip) {
// stream into an Artisan command's output or a batch insert
}
Keep firewall config in config/firewall.php. Parse once, cache the parsed Network objects with Cache::remember, and reuse. Constructing them per-request is cheap but not free; a hot middleware that loops 200 CIDRs will show up in profiles.
toLong() is 32-bit-unsafe. On 32-bit PHP, IPv4 longs overflow to negative floats. Guard with a runtime check or store the sprintf('%u', ...) string form. If you deploy to 32-bit containers, keep everything in toBin()/string form.
IPv6 has no meaningful toLong(). Use toBin() and compare with strcmp()/<=>. Some IPv6 paths may lean on GMP or BCMath depending on your platform — verify on your target image before assuming no extension is required.
Prefix parsing may normalize host bits. Network::parse('192.168.1.5/24') typically yields the network 192.168.1.0. Don't expect round-tripping of the input string; always read back via getNetwork() / getFirstIP().
Mixed address families. contains() called with an IPv6 object against an IPv4 network should return false (or throw) — always catch around contains() in user-facing paths so a malformed/foreign-family IP becomes a 422, not a 500.
$request->ip() is the proxy IP unless TrustProxies is configured. The most common "package is broken" report in Laravel is actually a middleware config issue. Set TrustProxies::$proxies explicitly or via TrustHosts/trustProxies in bootstrap/app.php on Laravel 11.
Iterating anything larger than a /16 (IPv4) or /64 (IPv6) will hang. Range::iterate() is a generator, but the consumer (batch insert, HTTP call) is usually the bottleneck. Guard the size: $network->getHosts() first, then refuse or chunk.
Lexicographic ordering is silently wrong. ORDER BY ip on a VARCHAR puts 10.0.0.10 before 10.0.0.9. Either order on the numeric/binary column, or in PHP sort by toLong()/toBin().
Private/reserved detection isn't a one-liner. filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) is the pragmatic answer; don't rely on the package for it unless you write the checks yourself.
Invalid input throws plain exceptions. The classes throw \Exception / \InvalidArgumentException rather than a package-specific hierarchy. Catch \Throwable (or \InvalidArgumentException if you've verified the source) and convert to a validation error at the boundary.
Last release is December 2022. It's MIT, small, and stable, but you may see PHP 8.2/8.3 deprecation notices (dynamic properties, implicit nullable params) depending on the release tag. Pin the exact version in composer.json, run your test suite, and if deprecations are noisy, fork the two or three files you actually use rather than waiting for upstream.
No Laravel casting, no facade, no collection macro. If you want $user->ip_address to be an IPv6 object, write the accessor/cast yourself — a half-page custom cast wrapping IP::parse() and (string) is all it takes.
Comparison pitfall. Two IPv4 objects aren't == if they're different instances, and there's no equals() you can count on across versions. Compare (string) $a === (string) $b or $a->toBin() === $b->toBin() instead.
Extension point: since everything is small, immutable-ish value objects, the clean way to extend (e.g., geolocation-aware networks, custom contains semantics for IPv4-mapped IPv6) is wrapping — a Firewall service that holds Network[] and short-circuits family mismatches — rather than subclassing Network. Subclassing tends to fight the static parse() factories.
How can I help you explore Laravel packages today?