murilochianfa/laravel-ipv6-patch
A small compatibility patch that adds IPv6 support to Laravel, fixing framework internals that assume IPv4 so requests, validation, and stored addresses handle IPv6 correctly.
murilochianfa/laravel-ipv6-patch is a small compatibility layer that fills the IPv6 gaps Laravel's HTTP/validation stack leaves behind: normalizing $request->ip() / ips(), accepting bracketed and CIDR-form IPv6 in validation, and keeping trusted-proxy handling sane when X-Forwarded-For contains v6 addresses. Treat it as infrastructure, not a feature — you install it once, verify it, and then it should be invisible.
Minimal install and first check:
composer require murilochianfa/laravel-ipv6-patch
php artisan vendor:publish --tag=ipv6-patch-config # check the exact tag in the README
php artisan config:cache
Read the published config/ipv6-patch.php (or similarly named) first — it tells you which patch groups exist (request IP resolution, validation, URL/host parsing, storage casts) and which are enabled by default. Then read the README's "what this patches" table and skim src/ to see which Laravel internals are being decorated; that list is what will break on a Laravel major upgrade, so it's the thing you want to know about before you ship.
Your first real use case is almost always "we're behind an ALB/Cloudflare and $request->ip() is returning ::ffff:203.0.113.7, an empty string, or something with a port on it." Add a throwaway route and test it from a v6-capable host:
Route::get('/whoami', fn (Request $r) => [
'ip' => $r->ip(),
'ips' => $r->ips(),
'host' => $r->getHost(),
]);
curl -6 https://your-app.test/whoami
curl https://your-app.test/whoami -H 'X-Forwarded-For: 2001:db8::1'
If ip() now comes back as a canonical, compressed, lowercase address with no brackets/port, the patch is doing its job and you can start wiring it into rate limiting, audit logs, and validation.
On v1.1.0: the release notes are only a compare/v1.0.0...v1.1.0 link, so read the diff before bumping. A 1.x minor should be additive (new patch targets, config keys, or normalization edge cases), with no breaking changes and no deprecations — but since there is no prior assessment to compare against, verify the composer.json constraint hasn't shifted (^10.0|^11.0 style) and skim the diff for changed default config values.
Normalize at the edge, once. Do the address cleanup in the patch's middleware (registered after TrustProxies, before throttling/auth), and let everything downstream assume canonical form. If you re-normalize in each controller you will eventually miss one, and one missed spot is enough to split a rate-limit bucket or create a duplicate audit row.
Configure trusted proxies with real CIDRs, not *. In Laravel 11 this is bootstrap/app.php → ->withMiddleware(fn ($m) => $m->trustProxies(at: [...])); in 10 and below it's App\Http\Middleware\TrustProxies::$proxies. Include v6 ranges (2001:db8::/32, 2600:1f00::/24 for AWS, 2400:cb00::/32 for Cloudflare). The patch can strip ports and brackets from [2001:db8::1]:443, but it cannot guess which hops are yours.
Validation. Laravel's ip rule already accepts v6, so the patch's value is in the shapes people actually paste in:
'ip' => ['required', 'ip'], // 2001:db8::1
'bracket' => ['required', 'ip'], // [2001:db8::1] — may need the patched rule
'cidr' => ['required', 'ip', 'ipv6'], // 2001:db8::/32
Use ipv6 explicitly when a field must not accept v4, and canonicalize before Rule::unique / Rule::in comparisons against a stored address column — string equality on IPs is the single most common source of "duplicate" tenant/allowlist rows.
Storage. Prefer Postgres inet or a VARBINARY(16)/BINARY(16) column plus a cast. If you must stay on VARCHAR, 45 chars covers every plain textual form (39 for full v6, 45 for IPv4-mapped) but not the bracketed form at 47 — normalize before persisting and you avoid the whole class of problem. A simple accessor/mutator pair that stores canonical compressed lowercase is usually enough:
protected function ipAddress(): Attribute
{
return Attribute::make(
set: fn (string $v) => inet_pton($v) === false ? $v : inet_ntop(inet_pton($v)),
);
}
Rate limiting. This is where the patch pays for itself:
RateLimiter::for('api', fn (Request $r) => Limit::perMinute(60)->by($r->ip()));
Because a single user typically controls an entire /64, IP-keyed limits on v6 are effectively 2^64 free retries. Bucket by /64 for abuse cases (substr(inet_pton($ip), 0, 8) on the binary form) and keep the full address for logging. Do this deliberately — the patch normalizes the string, it will not make that policy decision for you.
Testing. Exercise both families, not just the default:
$this->withServerVariables(['REMOTE_ADDR' => '2001:db8::1']);
$this->withHeaders(['X-Forwarded-For' => '2001:db8::1, 10.0.0.1']);
::1 is what you get from artisan serve and most local setups, so feature tests that pass locally can be silently exercising the v6 path while production is v4 — assert on the exact normalized string in at least one test per route that keys on IP.
Integration points to wire up deliberately: audit/login logs, password-reset and 2FA allowlists, ThrottleRequests middleware, geo/ASN lookup jobs (pass the canonical string into the job payload, never the raw header), and any where('ip', ...) lookups in existing code.
::ffff:203.0.113.7) are the #1 footgun. Whether you collapse them to 203.0.113.7 or keep them, be consistent across request handling, storage, and rate limits — otherwise the same client gets two buckets and two audit identities depending on the hop.fe80::1%eth0 fails filter_var(..., FILTER_VALIDATE_IP). If you accept addresses from operators (allowlists, peer config), strip %... explicitly rather than assuming the patch does.php artisan config:cache — a stale cached config silently disables patch groups and sends you hunting for a bug that isn't there.TrustProxies, before ThrottleRequests and auth. If you're on Laravel 11's bootstrap/app.php, middleware registration is centralized — don't assume the package's auto-registration lands where you need it; check php artisan middleware:list (or dump the resolved kernel) once.withoutMiddleware() in tests bypasses the patch. Any test that disables middleware is testing an IPv6 behavior that doesn't exist in production.dump($request->server->get('REMOTE_ADDR'), $request->headers->get('X-Forwarded-For'), $request->getTrustedProxies()). The answer is nearly always proxy config or header order, not normalization.ip(). Bracketed hosts (https://[2001:db8::1]/) can still trip getHost()/url() helpers; if you generate absolute URLs from a v6 host, test that path independently.Request::macro() or a custom validation rule over forking the package — forks of small patch packages rot fast against Laravel majors. If you do need to override behavior, check whether the relevant class is container-bound (and therefore swappable) before reaching for a fork.^1.1) and run your IP-dependent tests on every Laravel minor bump. For v1.1.0 specifically: read the compare diff, confirm your composer.json framework constraint still matches, and re-run the v4/v6 test matrix — a 1.x minor shouldn't require code changes, but the diff is the only source of truth here since the release notes carry no description.How can I help you explore Laravel packages today?