Real road distance and travel time in Laravel with OSRM
"Nearest store" features usually sort by straight-line distance: the haversine formula, a few lines of trigonometry, no API. It's fast and it's wrong in a way users notice. A store across a river or on the other side of
"Nearest store" features usually sort by straight-line distance: the haversine formula, a few lines of trigonometry, no API. It's fast and it's wrong in a way users notice. A store across a river or on the other side of a highway looks close and isn't.
How wrong? From downtown SΓ£o Paulo (PraΓ§a da SΓ©), measured with OSRM:
| Destination | By road | Travel time | Straight line | Road vs. straight |
|---|---|---|---|---|
| Av. Paulista | 3.8 km | 7 min | 2.6 km | +44% |
| Pinheiros | 8.6 km | 13 min | 6.5 km | +33% |
| Santos | 77.5 km | 77 min | 54.9 km | +41% |
Worse than the error itself, the ranking changes: the store that is closest in a straight line is not always the one you reach first.
This post builds a small Laravel service that answers "how far, and how long, by road" from one origin to many destinations, in a single request, with caching and a straight-line fallback when routing isn't available.
OSRM in two minutes
OSRM (Open Source Routing Machine) is a fast, open source routing engine built on OpenStreetMap data. The piece we need is its table service: give it one origin and N destinations and it returns a matrix of distances (meters) and durations (seconds) in one call.
There's a public demo server at router.project-osrm.org, which is fine for trying things out but not allowed for production use. Running your own takes a region extract and four commands with Docker. For example, Southeast Brazil:
wget https://download.geofabrik.de/south-america/brazil/sudeste-latest.osm.pbf
docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend osrm-extract -p /opt/car.lua /data/sudeste-latest.osm.pbf
docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend osrm-partition /data/sudeste-latest.osrm
docker run -t -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend osrm-customize /data/sudeste-latest.osrm
docker run -t -i -p 5000:5000 -v "${PWD}:/data" ghcr.io/project-osrm/osrm-backend osrm-routed --algorithm mld /data/sudeste-latest.osrm
Pick the smallest extract that covers your users: preprocessing needs RAM roughly proportional to the region, and a country is a lot more than a state. Check the osrm-backend README for current images and flags.
A table request looks like this. Note the order: OSRM takes longitude first, then latitude.
GET /table/v1/driving/-46.6333,-23.5505;-46.6559,-23.5614;-46.6820,-23.5880?sources=0&annotations=duration,distance
sources=0 says "only the first coordinate is an origin", so you get one row back instead of an NΓN matrix.
1. Configuration
// config/services.php
'osrm' => [
'url' => env('OSRM_URL', 'http://localhost:5000'),
],
2. The service
<?php
namespace App\Services\Routing;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;
class RoadDistances
{
/** How far OSRM may move a point to reach a road before we stop trusting the route. */
private const MAX_SNAP_METERS = 500;
/**
* @param array{lat: float, lng: float} $origin
* @param list<array{id: int|string, lat: float, lng: float}> $destinations
* @return list<array{id: int|string, meters: int, seconds: int|null, routed: bool}>
*/
public function from(array $origin, array $destinations): array
{
if ($destinations === []) {
return [];
}
$key = 'road-distances:'.md5(json_encode([
$this->rounded($origin),
array_map(fn (array $d) => [$d['id'], ...$this->rounded($d)], $destinations),
]));
if (($cached = Cache::get($key)) !== null) {
return $cached;
}
$table = $this->table($origin, $destinations);
$rows = [];
foreach (array_values($destinations) as $i => $destination) {
$meters = $table['distances'][0][$i + 1] ?? null;
$seconds = $table['durations'][0][$i + 1] ?? null;
$snapped = $table['destinations'][$i + 1]['distance'] ?? INF;
$rows[] = $meters !== null && $seconds !== null && $snapped <= self::MAX_SNAP_METERS
? ['id' => $destination['id'], 'meters' => (int) round($meters), 'seconds' => (int) round($seconds), 'routed' => true]
: ['id' => $destination['id'], 'meters' => (int) round($this->straightLine($origin, $destination)), 'seconds' => null, 'routed' => false];
}
// Only cache real answers. A fallback caused by an outage shouldn't stick for a day.
if ($table !== null) {
Cache::put($key, $rows, now()->addDay());
}
return $rows;
}
/** Great-circle distance in meters (haversine). */
public function straightLine(array $from, array $to): float
{
$dLat = deg2rad($to['lat'] - $from['lat']);
$dLng = deg2rad($to['lng'] - $from['lng']);
$h = sin($dLat / 2) ** 2
+ cos(deg2rad($from['lat'])) * cos(deg2rad($to['lat'])) * sin($dLng / 2) ** 2;
return 2 * 6371000 * asin(sqrt($h));
}
private function table(array $origin, array $destinations): ?array
{
$coordinates = collect([$origin, ...$destinations])
->map(fn (array $point) => $point['lng'].','.$point['lat'])
->implode(';');
try {
$table = Http::baseUrl(config('services.osrm.url'))
->timeout(5)
->get("/table/v1/driving/{$coordinates}", [
'sources' => 0,
'annotations' => 'duration,distance',
])
->throw()
->json();
} catch (Throwable $e) {
report($e);
return null;
}
if (($table['code'] ?? null) !== 'Ok') {
return null;
}
// An origin far from any road makes every route meaningless.
return ($table['sources'][0]['distance'] ?? INF) <= self::MAX_SNAP_METERS ? $table : null;
}
/** ~11 m precision: close enough to share a cache entry, far enough to not lie. */
private function rounded(array $point): array
{
return [round($point['lat'], 4), round($point['lng'], 4)];
}
}
What it does:
- One request for the whole batch. The table service returns every destination's distance and duration at once.
-
Never leaves the caller hanging. If OSRM is down, times out, or a destination has no route (OSRM returns
nullfor that cell), that row falls back to straight-line meters withrouted: falseandseconds: null. Your UI can show "~6.5 km" instead of an ETA. -
Doesn't trust a point that had to travel to find a road. OSRM never says "this point is in the ocean": it snaps every coordinate to the nearest road and routes from there. In my test, a point placed at sea was moved 62.8 km to a highway and came back as a perfectly normal 222 km route. The response tells you how far each point was moved (
destinations[].distance, andsources[].distancefor the origin), so anything moved more than 500 m falls back to straight line. Real addresses typically snap within a few dozen meters. - Caches real answers for a day, keyed by coordinates rounded to ~11 m. Users cluster around the same places, so a handful of upstream calls can serve a whole neighbourhood. Fallbacks are not cached, so a short outage doesn't stick.
OSRM durations come from road types and speed limits in OpenStreetMap, without live traffic. They're very good for ranking and for "about 15 min" estimates, not for promising an arrival time at rush hour.
3. An endpoint: stores sorted by travel time
<?php
namespace App\Http\Controllers;
use App\Models\Store;
use App\Services\Routing\RoadDistances;
use Illuminate\Http\Request;
class NearbyStoresController
{
public function __invoke(Request $request, RoadDistances $roads)
{
$data = $request->validate([
'lat' => ['required', 'numeric', 'between:-90,90'],
'lng' => ['required', 'numeric', 'between:-180,180'],
]);
$origin = ['lat' => (float) $data['lat'], 'lng' => (float) $data['lng']];
// Pre-filter by straight line: routing 2,000 stores to show 10 is wasted work,
// and osrm-routed caps table size (100 locations by default, see --max-table-size).
$stores = Store::query()
->get(['id', 'name', 'lat', 'lng'])
->sortBy(fn (Store $store) => $roads->straightLine($origin, ['lat' => $store->lat, 'lng' => $store->lng]))
->take(25)
->values();
$distances = collect($roads->from($origin, $stores->map->only(['id', 'lat', 'lng'])->all()))
->keyBy('id');
return $stores
->map(fn (Store $store) => ['name' => $store->name, ...$distances[$store->id]])
->sortBy(fn (array $row) => $row['seconds'] ?? PHP_INT_MAX)
->values();
}
}
// routes/api.php
Route::get('/nearby-stores', NearbyStoresController::class)->middleware('throttle:30,1');
GET /api/nearby-stores?lat=-23.5505&lng=-46.6333 now returns the 25 closest stores, ordered by how long it takes to drive there, with routed results first and straight-line estimates at the end.
With a few hundred stores, loading them all to pre-filter is fine. With thousands, do the first cut in SQL instead (a bounding box on lat/lng, or your database's spatial functions) and only rank the survivors.
Wrapping up
Straight-line distance is a fine first filter and a bad final answer. One OSRM table request, a day of cache and a haversine fallback give you real road distances and travel times without paying per request, and without your users discovering the river on their own.
If you're building a NativePHP Mobile app (v3 or v4), I packaged the on-device side of this as a plugin, Routing: on iOS it uses Apple's MapKit, on the device and free, and on Android you pick the provider: Mapbox, openrouteservice, Geoapify, Google, or an OSRM endpoint like the one in this post. Full disclosure, it's a paid plugin I built. Everything in this post works without it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.