isFullPostcode($query) => $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query), $this->isOutcode($query) => $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query), default => $this->lookupPlace($query), }; if ($result !== null) { Cache::put($cacheKey, $result, self::CACHE_TTL); } return $result; } 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'); $result = new LocationResult( query: $postcode, displayName: $data['postcode'], lat: $data['latitude'], lng: $data['longitude'], ); Postcode::updateOrCreate( ['postcode' => $normalised], [ 'outcode' => substr($normalised, 0, strlen($normalised) - 3), 'lat' => $data['latitude'], 'lng' => $data['longitude'], ], ); 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'); $result = new LocationResult( query: $outcode, displayName: $data['outcode'], lat: $data['latitude'], lng: $data['longitude'], ); Outcode::updateOrCreate( ['outcode' => $normalised], [ 'lat' => $data['latitude'], 'lng' => $data['longitude'], ], ); 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; } } }