Real-Time Courier/Order Tracking Implementation on Website
A user places an order and waits for the courier. A "My Orders" page with a "status: in transit" field is old school. The modern standard is a map with a live courier marker and a timer "will arrive in N minutes." Technically, this is a combination of three components: a courier's mobile device, a backend application, and a client browser.
Data Stream Architecture
[Courier Device]
GPS → POST /api/courier/location every 3–5s
↓
[Backend]
Save to Redis (TTL 30s)
Publish to Redis Pub/Sub channel order:{id}
↓
[WebSocket Server (Laravel Reverb / Pusher)]
Broadcast event LocationUpdated
↓
[Client Browser]
Update marker on map
Geopositions are not stored in PostgreSQL on every update—that's 720 records per hour per courier. We only write to the database when the order status changes and the final position when completed. Current position stays in Redis with TTL.
Orders Table
CREATE TABLE delivery_orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
courier_id BIGINT REFERENCES couriers(id),
status VARCHAR(50) NOT NULL DEFAULT 'pending',
-- pending | assigned | picked_up | in_transit | delivered | failed
address_lat DECIMAL(10, 8),
address_lng DECIMAL(11, 8),
address_text VARCHAR(500),
estimated_at TIMESTAMP,
delivered_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE delivery_status_log (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES delivery_orders(id),
status VARCHAR(50) NOT NULL,
lat DECIMAL(10, 8),
lng DECIMAL(11, 8),
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Courier API: Location Update
The endpoint is called from the courier's device every 3–5 seconds:
class CourierLocationController extends Controller
{
public function update(Request $request, DeliveryOrder $order): JsonResponse
{
$data = $request->validate([
'lat' => 'required|numeric|between:-90,90',
'lng' => 'required|numeric|between:-180,180',
]);
// Current position—only in Redis, with 60-second TTL
$key = "courier_location:{$order->courier_id}";
Redis::setex($key, 60, json_encode([
'lat' => $data['lat'],
'lng' => $data['lng'],
'order_id' => $order->id,
'ts' => now()->timestamp,
]));
// Broadcast to order customer
broadcast(new CourierLocationUpdated(
orderId: $order->id,
lat: $data['lat'],
lng: $data['lng'],
eta: $this->calculateEta($order, $data['lat'], $data['lng']),
));
return response()->json(['ok' => true]);
}
private function calculateEta(DeliveryOrder $order, float $lat, float $lng): ?int
{
// Approximate calculation by straight line—30 km/h average city speed
$distanceKm = $this->haversineKm($lat, $lng, $order->address_lat, $order->address_lng);
return (int) round($distanceKm / 30 * 60); // minutes
}
}
Laravel Event
class CourierLocationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly int $orderId,
public readonly float $lat,
public readonly float $lng,
public readonly ?int $eta,
) {}
public function broadcastOn(): Channel
{
return new PrivateChannel("order.{$this->orderId}");
}
public function broadcastWith(): array
{
return [
'lat' => $this->lat,
'lng' => $this->lng,
'eta' => $this->eta,
];
}
}
PrivateChannel—the client must be authenticated to subscribe. This prevents unauthorized users from subscribing to someone else's order channel.
Channel Authorization
// routes/channels.php
Broadcast::channel('order.{orderId}', function (User $user, int $orderId) {
return $user->id === DeliveryOrder::find($orderId)?->user_id;
});
Client Side: Map
import mapboxgl from 'mapbox-gl';
mapboxgl.accessToken = 'pk.eyJ...';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [orderLng, orderLat],
zoom: 14,
});
// Delivery address marker
new mapboxgl.Marker({ color: '#EF4444' })
.setLngLat([orderLng, orderLat])
.addTo(map);
// Courier marker
const courierMarker = new mapboxgl.Marker({ color: '#3B82F6' })
.setLngLat([initialLng, initialLat])
.addTo(map);
// WebSocket subscription
Echo.private(`order.${orderId}`)
.listen('CourierLocationUpdated', ({ lat, lng, eta }) => {
courierMarker.setLngLat([lng, lat]);
if (eta !== null) {
document.getElementById('eta').textContent =
eta < 2 ? 'Courier is nearby' : `Will arrive in ~${eta} min`;
}
});
Alternative to Mapbox—Yandex Maps API or Google Maps Platform. For CIS countries, Yandex is preferable for geocoding quality and coverage.
Smooth Marker Movement
Abrupt marker jumps every 3–5 seconds look rough. Solution—animation via requestAnimationFrame:
function animateMarker(marker, from, to, duration = 500) {
const start = performance.now();
function step(now) {
const t = Math.min((now - start) / duration, 1);
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; // easeInOut
const lng = from[0] + (to[0] - from[0]) * ease;
const lat = from[1] + (to[1] - from[1]) * ease;
marker.setLngLat([lng, lat]);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
Push Notifications on Status Change
Beyond live tracking, clients need notifications about transitions: "courier picked up order," "courier 5 minutes away," "order delivered." Implemented via Web Push API or SMS:
// In OrderStatusChanged event handler
if ($order->status === 'in_transit') {
$order->user->notify(new CourierPickedUpNotification($order));
}
Courier Offline Mode
If the courier's device loses connection—the mobile app buffers coordinates, which are sent in bulk when connection restores. The backend accepts a point array with timestamps and replays the path animation rather than jumping to the final position.
Timeline
- Basic tracking (Redis + broadcast + map): 4–5 days
- Private Channel authorization + access logic: 1 day
- ETA calculation by straight line: 0.5 day
- ETA via Routing API (OSRM / Google Directions): +1–2 days
- Marker animation + path smoothing: 1 day
- Push notifications on status change: 1–2 days
- Dispatcher admin panel (all couriers on map): 3–4 days







