middlewares/client-ip
A Laravel middleware for reliably resolving the client's real IP address, including requests passing through proxies, load balancers, and CDNs. Supports forwarded headers and configurable trusted proxies for accurate IP detection.
middlewares/client-ip is a single-class PSR-15 middleware. It reads REMOTE_ADDR from the request's server params, optionally consults X-Forwarded-For when the peer is a trusted proxy, and attaches the resolved address to the request as clientIp (configurable). That's the whole package — no service provider, no config file, no facade.
composer require middlewares/client-ip
Where to look first
vendor/middlewares/client-ip/src/ClientIp.php — ~40 lines, the entire public API (proxy(), attribute(), process()).vendor/middlewares/utils/src/Ip.php — the actual resolution logic, including CIDR matching and the X-Forwarded-For walk. This is where 90% of behavior lives.Minimal first use case (PSR-15 stack)
use Middlewares\ClientIp;
$stack = new Dispatcher([
(new ClientIp())->proxy('10.0.0.0/8', '172.16.0.0/12'),
$router,
]);
// Downstream handler:
$ip = $request->getAttribute('clientIp'); // ?string
Put it first in the pipeline so every subsequent middleware (rate limiter, geo lookup, audit logger, allowlist) can read the attribute.
For Laravel specifically: Laravel is not a PSR-15 host. If you only need $request->ip(), you already have it — TrustProxies does the same job. Reach for this package when (a) you're writing a framework-agnostic package that must run on both Laravel and PSR-15 runtimes, or (b) you're embedding a PSR-15 dispatcher (League\Route, a Slim/Mezzio stack) inside a Laravel app. In both cases, wrap it in a thin Laravel middleware that runs ClientIp::process() over a PSR-7 copy of the request and copies the attribute back — see below.
If your package ships PSR-15 middleware, keep ClientIp in the PSR-15 path and add a Laravel bridge only in src/Laravel/:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Middlewares\ClientIp;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class ResolveClientIp
{
public function handle(Request $request, Closure $next)
{
$factory = new Psr17Factory();
$psr = $factory->createServerRequest(
$request->getMethod(),
$request->fullUrl(),
['REMOTE_ADDR' => $request->server('REMOTE_ADDR')]
);
foreach ($request->headers->all() as $name => $values) {
$psr = $psr->withHeader($name, $values);
}
$captured = null;
$terminal = new class($captured) implements RequestHandlerInterface {
public function __construct(public ?string &$captured) {}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->captured = $request->getAttribute('clientIp');
return new Response();
}
};
(new ClientIp())
->proxy(...config('app.trusted_proxies', []))
->attribute('clientIp')
->process($psr, $terminal);
if ($captured !== null) {
$request->attributes->set('clientIp', $captured);
}
return $next($request);
}
}
The terminal handler is a stub whose only purpose is to let the middleware mutate the request before you read the attribute. Because PSR-7 requests are immutable, you cannot read the attribute off $psr after process() — you must read it inside the handler.
Keep one source of truth. Parse TRUSTED_PROXIES from env once and feed it to both ClientIp::proxy() (PSR-15 path) and Laravel's TrustProxies (native path) so a request never gets two different answers:
// config/app.php
'trusted_proxies' => array_filter(array_map('trim', explode(',', env('TRUSTED_PROXIES', '')))),
proxy() accepts both bare IPs and CIDR ranges (10.0.0.0/8, 2001:db8::/32).
(new ClientIp())
->proxy(...$proxies)
->attribute('resolvedClientIp');
Useful when your stack already has a clientIp attribute populated by another middleware (e.g., a CDN-aware resolver), or when you want the PSR-15 result to sit alongside Laravel's own $request->ip() rather than overwrite it.
Once the attribute is set, downstream code is trivial and framework-independent:
class RateLimiter implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$ip = $request->getAttribute('clientIp', '0.0.0.0');
if ($this->store->tooMany($ip)) {
return new Response(429);
}
return $handler->handle($request);
}
}
In the Laravel bridge, expose the same value to controllers via a request macro so call sites read naturally:
// AppServiceProvider::boot()
Request::macro('clientIp', fn () => $this->attributes->get('clientIp'));
Construct PSR-7 requests with explicit server params; never rely on the SAPI's REMOTE_ADDR:
$request = (new ServerRequest('GET', '/'))
->withServerParams(['REMOTE_ADDR' => '203.0.113.7'])
->withHeader('X-Forwarded-For', '198.51.100.1, 203.0.113.7');
$result = (new ClientIp())->proxy('203.0.113.7')->process($request, $handler);
// $handler->seen request attribute === '198.51.100.1'
REMOTE_ADDR → no attribute. The middleware skips withAttribute() entirely when it cannot resolve an address. Downstream getAttribute('clientIp') returns null, which is indistinguishable from "middleware not in the pipeline." Provide a default at the call site, or make the resolver explicit about "unknown."proxy() calls, X-Forwarded-For is ignored and REMOTE_ADDR wins. That's safe but wrong behind a load balancer. With proxy('0.0.0.0/0') (a tempting shortcut), anyone can spoof their IP via a header. Pin the actual CIDRs of your LB / ingress.TrustProxies already rewrites $request->ip() from forwarded headers, running ClientIp over $request->server('REMOTE_ADDR') on the bridged request is usually fine, but feeding it already-resolved headers is not. Pick one layer as authoritative, ideally TrustProxies, and use ClientIp only for its attribute shape.Middlewares\Utils\Ip walks X-Forwarded-For; it does not read CF-Connecting-IP, True-Client-IP, Forwarded, or X-Real-IP. Behind Cloudflare or a proxy that only sets X-Real-IP, you'll get the edge address, not the client. Wrap or replace the resolver rather than assuming.proxy() is append-only. There is no unproxy() / reset. If you reuse a ClientIp instance across requests (e.g., in a long-running worker), configure it once in the container and treat it as immutable.process() returns a new ServerRequestInterface; the $psr variable you passed in does not gain the attribute. In Laravel land, always read the attribute inside the terminal handler (or inside the next PSR-15 middleware), never after process() returns.Illuminate\Http\Request, pass $request->headers->all() as arrays so multi-valued X-Forwarded-For lines survive.::1 and 2001:db8::/32 are handled, but a proxy() entry that mixes an IPv4 CIDR with an IPv6 peer will simply fail the in_array/CIDR check. If your app runs behind a dual-stack balancer, register both families.REMOTE_ADDR? (3) is REMOTE_ADDR inside a declared proxy CIDR? (4) is X-Forwarded-For present and parseable? There are no logs and no exceptions.ClientIp::ATTRIBUTE; typos in ->attribute('clientIP') fail silently. Keep the name in a single constant in your own codebase.Middlewares\Utils\Ip (or your own resolver), not a feature request.$request->ip() plus TrustProxies. Adding this package there means adding nyholm/psr7 + a bridge middleware to reimplement what the framework already does.How can I help you explore Laravel packages today?