From 03540887ec1b5c6005c170769886ca4f0fbddc36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gonz=C3=A1lez=20Majoral?= Date: Thu, 28 May 2026 07:15:04 +0200 Subject: [PATCH 1/2] Add optional cache for IP locations. --- config/location.php | 24 ++++++ src/LocationManager.php | 71 +++++++++++++++- src/Position.php | 5 ++ tests/CloudflareTest.php | 2 + tests/GeoPluginTest.php | 1 + tests/Ip2locationioTest.php | 1 + tests/IpApiTest.php | 1 + tests/IpDataTest.php | 1 + tests/IpInfoLiteTest.php | 1 + tests/IpInfoTest.php | 1 + tests/KloudendTest.php | 1 + tests/LocationCacheTest.php | 163 ++++++++++++++++++++++++++++++++++++ tests/MaxMindTest.php | 3 + 13 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 tests/LocationCacheTest.php diff --git a/config/location.php b/config/location.php index 50b0a01..0d5052d 100644 --- a/config/location.php +++ b/config/location.php @@ -68,6 +68,30 @@ 'connect_timeout' => 3, ], + /* + |-------------------------------------------------------------------------- + | Cache + |-------------------------------------------------------------------------- + | + | Here you may configure the cache settings for geolocated IP results. + | When enabled, resolved positions are cached to avoid repeated API + | calls for the same IP address. + | + | ttl: duration in seconds to keep results in the cache (default: 1 hour). + | store: the cache store to use, or null to use the default store. + | prefix: the prefix used for cache keys. + | ignore_failed: whether to ignore failed cache attempts (default: true). + | + */ + + 'cache' => [ + 'enabled' => env('LOCATION_CACHE', false), + 'ttl' => env('LOCATION_CACHE_TTL', 3600), + 'store' => env('LOCATION_CACHE_STORE', null), + 'prefix' => env('LOCATION_CACHE_PREFIX', 'location'), + 'ignore_failed' => env('LOCATION_CACHE_IGNORE_FAILED', true), + ], + /* |-------------------------------------------------------------------------- | Localhost Testing diff --git a/src/LocationManager.php b/src/LocationManager.php index 7b84ad8..ecc9391 100644 --- a/src/LocationManager.php +++ b/src/LocationManager.php @@ -72,11 +72,76 @@ public function setDefaultDriver(): static */ public function get(?string $ip = null): Position|bool { - if ($location = $this->driver->get($this->request()->setIp($ip))) { - return $location; + $request = $this->request()->setIp($ip); + + if (! $this->cacheEnabled()) { + return $this->driver->get($request); + } + + $key = $this->cacheKey($request->getIp()); + + $cache = cache()->store($this->cacheStore()); + + /** + * The value can be: + * + * - Position instance (cache hit) + * - false (cache hit for failed lookup, if ignore_failed is false) + * - null (cache miss) + * + * @var Position|false|null $position + */ + $position = $cache->get($key); + + if (! is_null($position)) { + if ($position instanceof Position) { + $position->cached = true; + } + + return $position; + } + + $position = $this->driver->get($request); + + $ignoreFailed = config('location.cache.ignore_failed', true); + + if (! $ignoreFailed || ($ignoreFailed && $position !== false && ! $position->isEmpty())) { + $cache->put($key, $position, $this->cacheTtl()); } - return false; + return $position; + } + + /** + * Determine whether the cache is enabled. + */ + protected function cacheEnabled(): bool + { + return (bool) config('location.cache.enabled', false); + } + + /** + * Get the cache store to use. + */ + protected function cacheStore(): ?string + { + return config('location.cache.store'); + } + + /** + * Get the cache TTL. + */ + protected function cacheTtl(): int + { + return (int) config('location.cache.ttl', 3600); + } + + /** + * Get the cache key for an IP address. + */ + protected function cacheKey(string $ip): string + { + return config('location.cache.prefix', 'location') . '_' . $ip; } /** diff --git a/src/Position.php b/src/Position.php index 2d95fdc..c396b81 100644 --- a/src/Position.php +++ b/src/Position.php @@ -88,6 +88,11 @@ class Position implements Arrayable */ public ?string $timezone = null; + /** + * If the location was retrieved from cache. + */ + public bool $cached = false; + /** * Create a new position instance. */ diff --git a/tests/CloudflareTest.php b/tests/CloudflareTest.php index 8510de1..8448494 100644 --- a/tests/CloudflareTest.php +++ b/tests/CloudflareTest.php @@ -45,6 +45,7 @@ 'areaCode' => null, 'timezone' => 'Europe/London', 'driver' => Cloudflare::class, + 'cached' => false, ]); }); @@ -77,6 +78,7 @@ 'areaCode' => null, 'timezone' => null, 'driver' => Cloudflare::class, + 'cached' => false, ]); }); diff --git a/tests/GeoPluginTest.php b/tests/GeoPluginTest.php index 495de2f..cd00605 100644 --- a/tests/GeoPluginTest.php +++ b/tests/GeoPluginTest.php @@ -50,5 +50,6 @@ 'ip' => '66.102.0.0', 'timezone' => 'America/Toronto', 'driver' => get_class($driver), + 'cached' => false, ]); }); diff --git a/tests/Ip2locationioTest.php b/tests/Ip2locationioTest.php index 76e8f93..8b122ba 100644 --- a/tests/Ip2locationioTest.php +++ b/tests/Ip2locationioTest.php @@ -49,5 +49,6 @@ 'currencyCode' => null, 'timezone' => '-07:00', 'driver' => get_class($driver), + 'cached' => false, ]); }); diff --git a/tests/IpApiTest.php b/tests/IpApiTest.php index 40504f4..ae04c26 100644 --- a/tests/IpApiTest.php +++ b/tests/IpApiTest.php @@ -51,5 +51,6 @@ 'currencyCode' => 'USD', 'timezone' => 'America/Toronto', 'driver' => get_class($driver), + 'cached' => false, ]); }); diff --git a/tests/IpDataTest.php b/tests/IpDataTest.php index db3f352..4498417 100644 --- a/tests/IpDataTest.php +++ b/tests/IpDataTest.php @@ -52,5 +52,6 @@ 'currencyCode' => 'USD', 'timezone' => 'America/Toronto', 'driver' => get_class($driver), + 'cached' => false, ]); }); diff --git a/tests/IpInfoLiteTest.php b/tests/IpInfoLiteTest.php index b0f2c07..6ca94f1 100644 --- a/tests/IpInfoLiteTest.php +++ b/tests/IpInfoLiteTest.php @@ -48,5 +48,6 @@ 'ip' => '66.102.0.0', 'timezone' => null, 'driver' => get_class($driver), + 'cached' => false, ]); }); diff --git a/tests/IpInfoTest.php b/tests/IpInfoTest.php index 5328e1b..c1a53ea 100644 --- a/tests/IpInfoTest.php +++ b/tests/IpInfoTest.php @@ -47,5 +47,6 @@ 'ip' => '66.102.0.0', 'timezone' => 'America/Toronto', 'driver' => get_class($driver), + 'cached' => false, ]); }); diff --git a/tests/KloudendTest.php b/tests/KloudendTest.php index a9175fe..d658555 100644 --- a/tests/KloudendTest.php +++ b/tests/KloudendTest.php @@ -52,6 +52,7 @@ 'currencyCode' => 'USD', 'timezone' => 'America/Toronto', 'driver' => get_class($driver), + 'cached' => false, ]); }); diff --git a/tests/LocationCacheTest.php b/tests/LocationCacheTest.php new file mode 100644 index 0000000..37f33df --- /dev/null +++ b/tests/LocationCacheTest.php @@ -0,0 +1,163 @@ + true, + 'location.cache.ttl' => 3600, + 'location.cache.store' => 'array', + 'location.cache.prefix' => 'location', + ]); + + Cache::store(config('location.cache.store'))->flush(); +}); + +/** + * Get the configured cache store. + */ +function cacheStore(): \Illuminate\Cache\Repository +{ + return Cache::store(config('location.cache.store')); +} + +it('does not use the cache when cache is disabled', function () { + config(['location.cache.enabled' => false]); + + $driver = m::mock(Driver::class) + ->shouldAllowMockingProtectedMethods(); + + $driver->shouldReceive('get') + ->twice() + ->andReturn(new Position); + + Location::setDriver($driver); + + Location::get('1.2.3.4'); + Location::get('1.2.3.4'); +}); + +it('caches a resolved position', function () { + $position = new Position; + $position->ip = '1.2.3.4'; + $position->countryCode = 'US'; + + $driver = m::mock(Driver::class) + ->shouldAllowMockingProtectedMethods(); + + $driver->shouldReceive('get') + ->once() + ->andReturn($position); + + Location::setDriver($driver); + + $first = Location::get('1.2.3.4'); + $second = Location::get('1.2.3.4'); + + expect($first)->toBeInstanceOf(Position::class); + expect($second)->toBeInstanceOf(Position::class); + expect(cacheStore()->has('location_1.2.3.4'))->toBeTrue(); +}); + +it('returns the cached position on subsequent calls', function () { + $position = new Position; + $position->ip = '5.6.7.8'; + + cacheStore()->put('location_5.6.7.8', $position, 3600); + + $driver = m::mock(Driver::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + $driver->shouldReceive('process')->never(); + + Location::setDriver($driver); + + $result = Location::get('5.6.7.8'); + + expect($result)->toBeInstanceOf(Position::class); + expect($result->ip)->toBe('5.6.7.8'); +}); + +it('does not cache a failed lookup by default', function () { + $driver = m::mock(Driver::class) + ->shouldAllowMockingProtectedMethods(); + + $driver->shouldReceive('get') + ->twice() + ->andReturn(false); + + Location::setDriver($driver); + + Location::get('9.9.9.9'); + Location::get('9.9.9.9'); + + expect(cacheStore()->has('location_9.9.9.9'))->toBeFalse(); +}); + +it('caches a failed lookup when ignore_failed is false', function () { + config(['location.cache.ignore_failed' => false]); + + $driver = m::mock(Driver::class) + ->shouldAllowMockingProtectedMethods(); + + $driver->shouldReceive('get') + ->once() + ->andReturn(false); + + Location::setDriver($driver); + + Location::get('9.9.9.9'); + Location::get('9.9.9.9'); + + expect(cacheStore()->has('location_9.9.9.9'))->toBeTrue(); + expect(cacheStore()->get('location_9.9.9.9'))->toBeFalse(); +}); + +it('uses a custom cache prefix', function () { + config(['location.cache.prefix' => 'geo']); + + $position = new Position; + $position->ip = '1.2.3.4'; + $position->countryCode = 'US'; + + $driver = m::mock(Driver::class) + ->shouldAllowMockingProtectedMethods(); + + $driver->shouldReceive('get') + ->once() + ->andReturn($position); + + Location::setDriver($driver); + + Location::get('1.2.3.4'); + + expect(cacheStore()->has('geo_1.2.3.4'))->toBeTrue(); + expect(cacheStore()->has('location_1.2.3.4'))->toBeFalse(); +}); + +it('caches different IPs independently', function () { + $position = new Position; + $position->countryCode = 'US'; + + $driver = m::mock(Driver::class) + ->shouldAllowMockingProtectedMethods(); + + $driver->shouldReceive('get') + ->twice() + ->andReturn($position); + + Location::setDriver($driver); + + Location::get('1.1.1.1'); + Location::get('2.2.2.2'); + + expect(cacheStore()->has('location_1.1.1.1'))->toBeTrue(); + expect(cacheStore()->has('location_2.2.2.2'))->toBeTrue(); +}); diff --git a/tests/MaxMindTest.php b/tests/MaxMindTest.php index 8629255..551ec2a 100644 --- a/tests/MaxMindTest.php +++ b/tests/MaxMindTest.php @@ -67,6 +67,7 @@ 'ip' => '66.102.0.0', 'timezone' => 'America/Toronto', 'driver' => get_class($driver), + 'cached' => false, ]); }); @@ -97,6 +98,7 @@ 'areaCode' => null, 'timezone' => 'Europe/London', 'driver' => "Stevebauman\Location\Drivers\MaxMind", + 'cached' => false, ]); }); @@ -127,5 +129,6 @@ 'areaCode' => null, 'timezone' => null, 'driver' => "Stevebauman\Location\Drivers\MaxMind", + 'cached' => false, ]); }); From 2440e9819afdcfef530931e6b4487021957ab37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gonz=C3=A1lez=20Majoral?= Date: Thu, 28 May 2026 07:21:52 +0200 Subject: [PATCH 2/2] Remove unnecessary check in conditional. --- src/LocationManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LocationManager.php b/src/LocationManager.php index ecc9391..15e3ae6 100644 --- a/src/LocationManager.php +++ b/src/LocationManager.php @@ -105,7 +105,7 @@ public function get(?string $ip = null): Position|bool $ignoreFailed = config('location.cache.ignore_failed', true); - if (! $ignoreFailed || ($ignoreFailed && $position !== false && ! $position->isEmpty())) { + if (! $ignoreFailed || ($position !== false && ! $position->isEmpty())) { $cache->put($key, $position, $this->cacheTtl()); }