wikimedia/ip-utils
Wikimedia's PHP library for parsing, validating, and comparing IPv4 and IPv6 addresses, CIDR ranges, and single addresses. Check whether an IP falls within a range, normalize and format addresses, and handle IPv6 with ease—battle-tested in MediaWiki.
Install with Composer — there is no service provider, facade, or config file to publish:
composer require wikimedia/ip-utils
The entire public surface is static or factory-based, so nothing needs registering in config/app.php. Start by reading two files in vendor/wikimedia/ip-utils/src/:
IPUtils.php — the static helper toolbox (isValid, isIPv4, isIPv6, isInRange, isInRanges, sanitizeIP, prettifyIP, toHex, formatHex, combineRanges, getRFC1918Ranges).IPBlock.php — the value object for a CIDR block or start/end range (newFromCIDR, newFromRange, contains, getStart, getEnd, getCIDR).IP.php, IPv4.php, IPv6.php, IPRange.php, and IPset.php are the lower-level building blocks; you rarely touch them directly.
First use case: validating and normalizing an inbound IP before doing anything else with it.
use Wikimedia\IPUtils;
$ip = IPUtils::sanitizeIP($request->ip());
if ($ip === null || !IPUtils::isValid($ip)) {
abort(400, 'Bad client address');
}
$ip = IPUtils::prettifyIP($ip); // canonical compressed IPv6 / dotted IPv4
From there, the natural second step is a CIDR check — IPUtils::isInRange($ip, '10.0.0.0/8') — which is the operation you will reach for most often in Laravel apps (admin allowlists, internal-traffic detection, geo/network gating).
Form Request / validation rule. Wrap IPUtils in a reusable rule rather than sprinkling static calls through controllers:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Wikimedia\IPUtils;
class IpAddress implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || !IPUtils::isValid(IPUtils::sanitizeIP($value) ?? '')) {
$fail("The {$attribute} must be a valid IPv4 or IPv6 address.");
}
}
}
Apply it to user-supplied fields (webhook source IPs, trusted proxy lists, per-tenant allowlists) — never trust $request->ip() implicitly.
Middleware for CIDR-gated routes. A classic admin-panel or internal-API guard:
public function handle(Request $request, Closure $next): Response
{
$ranges = config('access.internal_ranges', ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']);
if (!IPUtils::isInRanges(IPUtils::sanitizeIP($request->ip()), $ranges)) {
abort(403);
}
return $next($request);
}
isInRanges short-circuits on the first match, so it is the right choice for lists. isInRange is the single-range variant and throws on malformed input, so guard it.
Trusted proxy handling. $request->ip() is only meaningful if TrustProxies is correct. Combine the two: verify the immediate peer ($request->server('REMOTE_ADDR')) is in your load-balancer range with IPUtils::isInRange, and let Laravel's TrustProxies middleware resolve the client address from X-Forwarded-For before your own middleware runs.
Model casts for stored IPs. Store addresses as VARBINARY(16) for indexability and correct ordering. IPUtils::toHex() returns a 32-char hex string, which converts cleanly:
// migration
$table->binary('ip', 16)->nullable()->index();
// cast
protected function ip(): Attribute
{
return Attribute::make(
get: fn ($value) => $value === null ? null : IPUtils::formatHex(bin2hex($value)),
set: fn ($value) => $value === null ? null : hex2bin(IPUtils::toHex($value)),
);
}
Normalizing through toHex/formatHex also collapses IPv4-mapped IPv6 (::ffff:1.2.3.4) to 1.2.3.4, which prevents duplicate rows for the same client.
Range arithmetic for reports. IPBlock::newFromCIDR('10.0.0.0/24') exposes getStart(), getEnd(), getSize(), and contains(). Use it to summarize login/audit tables by subnet, and IPUtils::combineRanges() to merge overlapping entries before writing to config or displaying rules.
Bulk checks with IPset. When checking one address against thousands of ranges (e.g. a scraped deny list), build an IPset once and reuse it in the request or a queued job rather than looping isInRange.
Config-driven allowlists. Keep CIDRs in config/ as arrays and validate them at boot (an artisan command or a test) with IPUtils::isValid/IPBlock::newFromCIDR so a typo fails loudly in CI rather than silently denying traffic in production.
symfony/http-foundation's IpUtils, or mlocati/ip-lib) may be the safer call if GPL is unacceptable to your legal team.sanitizeIP() does not validate. It strips whitespace, brackets, and IPv6 zone IDs (fe80::1%eth0) and normalizes ::ffff: forms, but it can return input that is still invalid. Always pair it with isValid().isInRange() throws, isInRanges() returns false. Wrap isInRange in try/catch or pre-validate at the boundary; an uncaught InvalidArgumentException from a user-supplied CIDR is a 500.2001:DB8::1 and 2001:db8:0:0:0:0:0:1 are the same host. Run everything through sanitizeIP/toHex at the edge so database lookups, cache keys, and rate-limiter keys (RateLimiter::for(...) on IP) are consistent.prettifyIP() is cosmetic. Use it for display and logs; use toHex() for storage and equality. Do not mix them in lookups.IPUtils::isPrivate() and isLoopback() only cover RFC 1918 / RFC 4193 / loopback blocks. Link-local, CGNAT (100.64.0.0/10), and cloud metadata addresses (169.254.169.254) are not "private" — add them to your range lists explicitly./128 is trivially bypassed; consider grouping by /64 or /56 using IPBlock::newFromCIDR($ip . '/64')->getCIDR() when you need per-network limits.final-leaning and value-oriented; build your own IpAccessService / IpAllowlist around them instead of subclassing IPBlock. That gives you a single seam to mock in tests and to swap the underlying library.How can I help you explore Laravel packages today?