DEV3 Technologies home

Nearest-rider assignment

Find the closest available courier with a bounding-box prefilter and a haversine distance, then claim them atomically for a delivery.

LaravelDatabasegeodispatchhaversinedelivery

Code

<?php

namespace App\Dispatch;

use App\Models\Delivery;
use App\Models\Rider;
use Illuminate\Support\Facades\DB;

final class NearestRider
{
    private const EARTH_KM = 6371.0;

    /** Returns the assigned rider, or null when nobody free is within range. */
    public function assign(Delivery $delivery, float $maxKm = 5.0): ?Rider
    {
        [$lat, $lng] = [(float) $delivery->pickup_lat, (float) $delivery->pickup_lng];

        // A cheap box first (index friendly), then the exact distance on the survivors.
        $dLat = $maxKm / 111.0;
        $dLng = $maxKm / (111.0 * max(0.01, cos(deg2rad($lat))));

        return DB::transaction(function () use ($delivery, $lat, $lng, $dLat, $dLng, $maxKm) {
            $rider = Rider::query()
                ->where('status', 'available')
                ->where('last_seen_at', '>', now()->subMinutes(2)) // ignore stale locations
                ->whereBetween('lat', [$lat - $dLat, $lat + $dLat])
                ->whereBetween('lng', [$lng - $dLng, $lng + $dLng])
                ->selectRaw('riders.*, ('.self::EARTH_KM.' * 2 * ASIN(SQRT(
                    POW(SIN(RADIANS(lat - ?) / 2), 2) +
                    COS(RADIANS(?)) * COS(RADIANS(lat)) * POW(SIN(RADIANS(lng - ?) / 2), 2)
                ))) AS km', [$lat, $lat, $lng])
                ->having('km', '<=', $maxKm)
                ->orderBy('km')
                ->lockForUpdate()   // another dispatcher cannot grab the same rider
                ->first();

            if (! $rider) {
                return null;
            }

            $rider->update(['status' => 'busy']);
            $delivery->update(['rider_id' => $rider->id, 'status' => 'assigned']);

            return $rider;
        });
    }
}

How it works

What it solves

Dispatch needs to answer one question quickly: which free courier is closest to this pickup point? Computing a great-circle distance for every rider in the city on each order does not scale, and two orders arriving together must not both claim the same person.

How it works

The method first builds a bounding box around the pickup point. One degree of latitude is about 111 km, and a degree of longitude shrinks with the cosine of the latitude, so the box width is adjusted accordingly. whereBetween on latitude and longitude uses ordinary indexes and discards almost every rider before any trigonometry runs.

For the remaining rows, a raw select computes the haversine distance in kilometres with bound parameters, then having removes anyone beyond the radius and orderBy puts the closest first. Only riders marked available whose location was updated in the last two minutes are considered, so an app that went offline does not receive jobs.

The query ends with lockForUpdate() inside a transaction. A concurrent dispatcher waits, and by the time it reads the row the rider is already marked busy, so it moves on to the next closest.

Adapting it

  • Offer the job to the top three riders in turn instead of auto-assigning.
  • Weight the order by rating or current load as a second sort key.
  • Use PostGIS or MySQL spatial indexes if you track many thousands of riders.

Pitfalls

  • having on an alias works in MySQL; PostgreSQL needs a subquery.
  • Straight-line distance ignores roads, so keep the radius generous and confirm with a routing service for ETAs.

How to use it

  1. Add lat, lng, status and last_seen_at to riders, and an index on (status, lat, lng).
  2. Have the rider app post its position regularly and update last_seen_at.
  3. Put NearestRider.php in app/Dispatch/.
  4. Dispatch a delivery: $rider = app(NearestRider::class)->assign($delivery, 4.0);
  5. If it returns null, retry from a queued job after a short delay or widen the radius.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets