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:

Formula on my blog

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

use Illuminate\Database\Eloquent\Builder;

/**
 * Scope a query to include only records within a given radius.
 *
 * @param  \Illuminate\Database\Eloquent\Builder  $query
 * @param  float|int  $distance
 * @param  float  $lat
 * @param  float  $lng
 * @param  string  $units
 * @return \Illuminate\Database\Eloquent\Builder
 */
public function scopeDistance(Builder $query, float|int $distance, float $lat, float $lng, string $units = 'kilometers')
{
    if (!$distance || !$lat || !$lng) {
        return $query; // nothing to filter
    }

    $radius = $units === 'miles' ? 3959 : 6371; // Earth radius

    // Haversine formula (placeholders only)
    $haversine = "(
        ? * ACOS(
            COS(RADIANS(?)) *
            COS(RADIANS(coordinates.latitude)) *
            COS(RADIANS(coordinates.longitude) - RADIANS(?)) +
            SIN(RADIANS(?)) *
            SIN(RADIANS(coordinates.latitude))
        )
    )";

    $bindings = [$radius, $lat, $lng, $lat];

    return $query->whereHas('coordinates', fn ($q) =>
        $q->selectRaw("ROUND($haversine, 2) AS distance", $bindings)
          ->having('distance', '<=', $distance)
          ->orderBy('distance', 'asc')
    );
}
Enter fullscreen mode Exit fullscreen mode

Decisive details:

  • Units are explicit. No hidden constants—callers pass 'miles' or 'kilometers'.
  • Select + having. We compute distance and filter in one trip to the database.
  • Relation-aware. whereHas ensures we only pull Trip rows that have at least one qualifying Coordinate.

Practical Example: Finding Cars Near a Trip

$nearbyCars = Trip::byDistance(
        distance: 10, // 10 km radius
        lat: $trip->pickup_lat,
        lng: $trip->pickup_lng,
        units: 'kilometers'
    )
    ->with('coordinates') // eager-load matches
    ->get();
Enter fullscreen mode Exit fullscreen mode

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.

$query->selectRaw($haversine)
      ->having('distance', '<=', $distance)
      ->orderBy('distance');
Enter fullscreen mode Exit fullscreen mode

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

test('returns coordinates within radius', function () {
    $origin = ['lat' => 10.5000, 'lng' => -66.9167];

    Coordinate::factory()->create(['latitude' => 10.51, 'longitude' => -66.91]); // ~1 km
    Coordinate::factory()->create(['latitude' => 11.00, 'longitude' => -67.00]); // ~70 km

    $results = Trip::byDistance(5, $origin['lat'], $origin['lng'])->get();

    expect($results)->toHaveCount(1);
});
Enter fullscreen mode Exit fullscreen mode

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:

  • Zero vendor lock-in. Works the same across MySQL, MariaDB, Postgres, or SQL Server.
  • Uncompromising performance. The database filters; PHP just maps results to resources.
  • Readable, testable code. Your controllers stay slim, your models self-document intent.

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