isFullPostcode($query)) { return $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query); } if ($this->isOutcode($query)) { return $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query); } $cacheKey = 'place:'.strtolower(preg_replace('/\s+/', '', $query)); $cached = Cache::get($cacheKey); if ($cached instanceof LocationResult) { return $cached; } $result = $this->lookupPlace($query); if ($result !== null) { Cache::put($cacheKey, $result, self::CACHE_TTL); } return $result; } /** * Reverse-geocode coordinates to a general UK area label (e.g. "Peterborough"). * * Coordinates are bucketed to ~1km (2dp) before lookup so the cache is shared * across nearby searches and nothing more precise than the stored bucket is * ever queried. Returns null if the area cannot be determined. */ public function reverseResolve(float $lat, float $lng): ?string { $latBucket = round($lat, 2); $lngBucket = round($lng, 2); $cacheKey = "revgeo:{$latBucket},{$lngBucket}"; $cached = Cache::get($cacheKey); if (is_string($cached)) { return $cached; } $label = $this->lookupArea($latBucket, $lngBucket); if ($label !== null) { Cache::put($cacheKey, $label, self::CACHE_TTL); } return $label; } private function lookupArea(float $lat, float $lng): ?string { $url = self::BASE_URL.'/postcodes'; // radius=2000 (postcodes.io max): we query the ~1km bucket centroid, which // can sit up to ~780m from any real point in the bucket. The default 100m // radius misses in low-density areas, so widen it to guarantee a hit. $logUrl = $url.'?lon='.$lng.'&lat='.$lat.'&radius=2000&limit=1'; try { $response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(5) ->get($url, ['lon' => $lng, 'lat' => $lat, 'radius' => 2000, 'limit' => 1])); if (! $response->successful()) { return null; } $results = $response->json('result'); if (! is_array($results) || ! isset($results[0]) || ! is_array($results[0])) { return null; } // Prefer the most human "town/district" field, falling back to broader areas. foreach (['admin_district', 'parish', 'admin_ward', 'region', 'country'] as $field) { $value = $results[0][$field] ?? null; if (is_string($value) && $value !== '') { return $value; } } return null; } catch (Throwable $e) { Log::error('PostcodeService: reverse geocode failed', [ 'lat' => $lat, 'lng' => $lng, 'error' => $e->getMessage(), ]); return null; } } private function normalisePostcode(string $value): string { return strtoupper(preg_replace('/\s+/', '', $value)); } private function isFullPostcode(string $query): bool { return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query); } private function isOutcode(string $query): bool { return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query); } private function lookupLocalPostcode(string $postcode): ?LocationResult { $normalised = $this->normalisePostcode($postcode); $row = Postcode::find($normalised); if ($row === null) { return null; } return new LocationResult( query: $postcode, displayName: $this->formatPostcode($normalised), lat: $row->lat, lng: $row->lng, ); } private function lookupLocalOutcode(string $outcode): ?LocationResult { $normalised = strtoupper(trim($outcode)); $row = Outcode::find($normalised); if ($row === null) { return null; } return new LocationResult( query: $outcode, displayName: $normalised, lat: $row->lat, lng: $row->lng, ); } private function formatPostcode(string $normalised): string { // Insert the single space before the last 3 chars ("SW1A1AA" -> "SW1A 1AA"). if (strlen($normalised) < 5) { return $normalised; } return substr($normalised, 0, -3).' '.substr($normalised, -3); } private function lookupPostcode(string $postcode): ?LocationResult { $normalised = $this->normalisePostcode($postcode); $url = self::BASE_URL.'/postcodes/'.$normalised; try { $response = $this->apiLogger->send('postcodes_io', 'GET', $url, fn () => Http::timeout(10)->get($url)); if (! $response->successful()) { return null; } $data = $response->json('result'); if (! is_array($data) || ! isset($data['postcode'], $data['latitude'], $data['longitude'])) { return null; } $result = new LocationResult( query: $postcode, displayName: $data['postcode'], lat: $data['latitude'], lng: $data['longitude'], ); try { Postcode::updateOrCreate( ['postcode' => $normalised], [ 'outcode' => substr($normalised, 0, strlen($normalised) - 3), 'lat' => $data['latitude'], 'lng' => $data['longitude'], ], ); } catch (Throwable $e) { Log::warning('PostcodeService: failed to persist postcode after HTTP fallback', [ 'postcode' => $normalised, 'error' => $e->getMessage(), ]); } return $result; } catch (Throwable $e) { Log::error('PostcodeService: postcode lookup failed', [ 'postcode' => $postcode, 'error' => $e->getMessage(), ]); return null; } } private function lookupOutcode(string $outcode): ?LocationResult { $normalised = strtoupper(trim($outcode)); $url = self::BASE_URL.'/outcodes/'.$normalised; try { $response = $this->apiLogger->send('postcodes_io', 'GET', $url, fn () => Http::timeout(10)->get($url)); if (! $response->successful()) { return null; } $data = $response->json('result'); if (! is_array($data) || ! isset($data['outcode'], $data['latitude'], $data['longitude'])) { return null; } $result = new LocationResult( query: $outcode, displayName: $data['outcode'], lat: $data['latitude'], lng: $data['longitude'], ); try { Outcode::updateOrCreate( ['outcode' => $normalised], [ 'lat' => $data['latitude'], 'lng' => $data['longitude'], ], ); } catch (Throwable $e) { Log::warning('PostcodeService: failed to persist outcode after HTTP fallback', [ 'outcode' => $normalised, 'error' => $e->getMessage(), ]); } return $result; } catch (Throwable $e) { Log::error('PostcodeService: outcode lookup failed', [ 'outcode' => $outcode, 'error' => $e->getMessage(), ]); return null; } } private function lookupPlace(string $place): ?LocationResult { $url = self::BASE_URL.'/places'; $logUrl = $url.'?q='.urlencode($place).'&limit=1'; try { $response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(10) ->get($url, ['q' => $place, 'limit' => 1])); if (! $response->successful()) { return null; } $results = $response->json('result'); if (empty($results)) { return null; } $data = $results[0]; return new LocationResult( query: $place, displayName: $data['name_1'], lat: $data['latitude'], lng: $data['longitude'], ); } catch (Throwable $e) { Log::error('PostcodeService: place lookup failed', [ 'place' => $place, 'error' => $e->getMessage(), ]); return null; } } }