Server-Side Geolocation Filtering in Laravel with the Haversine Formula cover image

Server-Side Geolocation Filtering in Laravel with the Haversine Formula

Eduar Bastidas • July 5, 2025

tips

Distance-aware queries are a core feature for modern apps—whether you're matching riders and drivers, showing events around a user, or surfacing the nearest warehouses for same-day delivery. The fastest, most accurate way to deliver those results is to compute great-circle distance inside your SQL engine with the Haversine formula, then let Eloquent give you a fluent, testable API.

Why Haversine?

Mathematically sound. Haversine treats Earth as a sphere, producing realistic distances at planetary scale without the overhead of full ellipsoidal calculations.

Pushes work to the DB. The heavy trig runs where your data already lives, slicing result sets before PHP ever sees them.

Vendor-agnostic. Works in MySQL, MariaDB, Postgres, SQL Server—anything that supports basic trig functions.

The Math — Haversine Engine, No Mystery

The Haversine formula gives the great-circle distance between two points on a sphere:

d=2rarcsin(sin2(Δφ2)+cosφ1cosφ2sin2(Δλ2)) d = 2r \arcsin\left(\sqrt{\sin^2\left(\frac{\Delta\varphi}{2}\right) + \cos\varphi_1 \cos\varphi_2 \sin^2\left(\frac{\Delta\lambda}{2}\right)}\right)

Where:

Plugging those values in returns the shortest surface distance (the great-circle distance)—ideal for any geospatial filter you need on the server.

The Data Model

We'll generalise first and then pivot to a concrete example:

Table Purpose Key Columns
trips (or any parent entity) The object you're filtering from id, …
coordinates Latitude/longitude pairs representing nearby entities (cars, stores, users, etc.) id, latitude, longitude, trip_id

Trip ➜ hasMany ➜ Coordinate
Coordinate ➜ belongsTo ➜ Trip

You can just as easily embed latitude and longitude directly on the primary model. The scope below works in either scenario; choosing a relation simply keeps the example laser-focused on nearest cars for a given trip.

The Scope

1/**
2 * Scope a query to include only records within a given radius.
3 *
4 * @param \Illuminate\Database\Eloquent\Builder $query
5 * @param float|int $distance
6 * @param float $lat
7 * @param float $lng
8 * @param string $units
9 * @return \Illuminate\Database\Eloquent\Builder
10 */
11public static function scopeByDistance(
12 $query,
13 $distance,
14 $lat,
15 $lng,
16 string $units = 'kilometers'
17) {
18 $query->when($distance && $lat && $lng, function ($query) use ($lat, $lng, $units, $distance) {
19 // Decide Earth-radius constant
20 $greatCircleRadius = match ($units) {
21 'miles' => 3959, // mi
22 'kilometers', default => 6371, // km
23 };
24 
25 // Haversine select expression
26 $haversine = sprintf(
27 'ROUND(( %d * ACOS( COS( RADIANS(%s) ) ' .
28 '* COS( RADIANS( latitude ) ) ' .
29 '* COS( RADIANS( longitude ) - RADIANS(%s) ) ' .
30 '+ SIN( RADIANS(%s) ) * SIN( RADIANS( latitude ) ) ) ), 2) AS distance',
31 $greatCircleRadius,
32 $lat,
33 $lng,
34 $lat
35 );
36 
37 // Filter through the coordinates relation
38 $query->whereHas('coordinates', fn ($coordinate) => $coordinate
39 ->select(DB::raw($haversine))
40 ->having('distance', '<=', $distance)
41 ->orderBy('distance', 'ASC')
42 );
43 });
44 
45 return $query;
46}

Decisive details:

Practical Example: Finding Cars Near a Trip

1$nearbyCars = Trip::byDistance(
2 distance: 10, // 10 km radius
3 lat: $trip->pickup_lat,
4 lng: $trip->pickup_lng,
5 units: 'kilometers'
6 )
7 ->with('coordinates') // eager-load matches
8 ->get();

Swap Trip and Coordinate for any other domain pair—warehouses and parcels, events and attendees, sellers and buyers.

If You Store Lat/Lng on the Same Table

Just remove the whereHas wrapper and call selectRaw / having directly on $query. Everything else remains identical.

1$query->selectRaw($haversine)
2 ->having('distance', '<=', $distance)
3 ->orderBy('distance');

Performance Power-Ups

Technique Why It Matters
Composite index on (latitude, longitude) Accelerates simple bounding-box prefilters.
Bounding box guard-clause whereBetween lat/lng to skip obvious misses before running trig.
Spatial columns POINT + SPATIAL INDEX (MySQL) or PostGIS geography types let you switch to ST_DistanceSphere later with a one-liner.
Query caching City-scale apps see repetitive origins—cache JSON responses for 30–60 s.

Testing the Scope

1test('returns coordinates within radius', function () {
2 $origin = ['lat' => 10.5000, 'lng' => -66.9167];
3 
4 Coordinate::factory()->create(['latitude' => 10.51, 'longitude' => -66.91]); // ~1 km
5 Coordinate::factory()->create(['latitude' => 11.00, 'longitude' => -67.00]); // ~70 km
6 
7 $results = Trip::byDistance(5, $origin['lat'], $origin['lng'])->get();
8 
9 expect($results)->toHaveCount(1);
10});

Fast, deterministic, no external APIs.

Final Thoughts

The Haversine formula is universally applicable—anywhere you need "X within Y km". By embedding it in a concise Eloquent scope, you gain:

Copy the scope, adjust the relationship (or not), and ship precise geospatial queries!.