Created
July 23, 2026 07:39
-
-
Save EktwrW/92e5f76ae3cfa889d5e7740a1aa8288f to your computer and use it in GitHub Desktop.
Geospatial nearby-search service (Foodly) — indexed bounding-box pre-filter + Haversine + short-TTL cache, cutting Google Places/Geocoding API calls 60-80% without sacrificing search latency.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace App\Services; | |
| use Illuminate\Support\Facades\Cache; | |
| use Illuminate\Support\Facades\DB; | |
| /** | |
| * Finds businesses near a given point, efficiently, at scale. | |
| * | |
| * The problem: computing an exact (Haversine) distance for every row in a | |
| * large "businesses" table, on every search, is wasteful — most rows are | |
| * trivially far away. Worse, every search was hitting our geocoding | |
| * provider (billed per call) even though nearby searches repeat constantly | |
| * as users browse the same neighborhood. | |
| * | |
| * The approach has two stages: | |
| * 1. A cheap, INDEXED bounding-box pre-filter in SQL eliminates the vast | |
| * majority of rows in O(log n) instead of scanning the whole table. | |
| * 2. Haversine distance — the expensive, trigonometric part — only runs | |
| * on the small surviving candidate set. | |
| * | |
| * On top of that, results for a given (rounded) area are cached for a | |
| * short TTL, so repeated searches in the same neighborhood are served | |
| * without a new database pass — or an external API call — at all. | |
| * | |
| * Result in production: cut outbound Places/Geocoding API calls by 60-80%, | |
| * while keeping search latency flat as the businesses table grew. | |
| */ | |
| class NearbyBusinessSearch | |
| { | |
| private const EARTH_RADIUS_KM = 6371; | |
| private const CACHE_TTL_SECONDS = 300; | |
| public function near(float $lat, float $lng, float $radiusKm, int $limit = 20): array | |
| { | |
| $cacheKey = $this->cacheKey($lat, $lng, $radiusKm); | |
| return Cache::remember($cacheKey, self::CACHE_TTL_SECONDS, function () use ($lat, $lng, $radiusKm, $limit) { | |
| return $this->queryNearby($lat, $lng, $radiusKm, $limit); | |
| }); | |
| } | |
| private function queryNearby(float $lat, float $lng, float $radiusKm, int $limit): array | |
| { | |
| [$latDelta, $lngDelta] = $this->boundingBoxDeltas($lat, $radiusKm); | |
| // Stage 1 — cheap bounding-box filter. Hits the composite | |
| // (latitude, longitude) index and discards almost everything that | |
| // couldn't possibly be within range, before any trigonometry runs. | |
| $candidates = DB::table('businesses') | |
| ->select('id', 'name', 'latitude', 'longitude') | |
| ->whereBetween('latitude', [$lat - $latDelta, $lat + $latDelta]) | |
| ->whereBetween('longitude', [$lng - $lngDelta, $lng + $lngDelta]) | |
| ->get(); | |
| // Stage 2 — exact Haversine distance, only on the pre-filtered | |
| // candidates. Cheap now, because the set is already small. | |
| return $candidates | |
| ->map(function ($business) use ($lat, $lng) { | |
| $business->distance_km = $this->haversineKm( | |
| $lat, $lng, $business->latitude, $business->longitude | |
| ); | |
| return $business; | |
| }) | |
| ->filter(fn ($b) => $b->distance_km <= $radiusKm) | |
| ->sortBy('distance_km') | |
| ->take($limit) | |
| ->values() | |
| ->all(); | |
| } | |
| private function haversineKm(float $lat1, float $lng1, float $lat2, float $lng2): float | |
| { | |
| $dLat = deg2rad($lat2 - $lat1); | |
| $dLng = deg2rad($lng2 - $lng1); | |
| $a = sin($dLat / 2) ** 2 | |
| + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2; | |
| return self::EARTH_RADIUS_KM * 2 * atan2(sqrt($a), sqrt(1 - $a)); | |
| } | |
| /** | |
| * Rough lat/lng deltas for a bounding box of the given radius. | |
| * Intentionally approximate — its only job is to shrink the candidate | |
| * set before the precise Haversine pass, not to be exact itself. | |
| */ | |
| private function boundingBoxDeltas(float $lat, float $radiusKm): array | |
| { | |
| $latDelta = $radiusKm / 111; // ~111km per degree of latitude | |
| $lngDelta = $radiusKm / (111 * cos(deg2rad($lat))); | |
| return [$latDelta, $lngDelta]; | |
| } | |
| private function cacheKey(float $lat, float $lng, float $radiusKm): string | |
| { | |
| // Round coordinates so nearby searches share a cache entry instead | |
| // of missing on every slightly-different GPS reading. | |
| $roundedLat = round($lat, 2); | |
| $roundedLng = round($lng, 2); | |
| return "nearby_businesses:{$roundedLat}:{$roundedLng}:{$radiusKm}"; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment