Service-area geofence check
Test whether a latitude and longitude falls inside a stored polygon service area using ray casting, with a bounding-box shortcut.
LaravelUtilitiesgeofencepolygongeojsonlocation
Code
<?php
namespace App\Geo;
final class Geofence
{
/**
* @param list<array{0: float, 1: float}> $ring polygon vertices as [lng, lat] (GeoJSON order)
*/
public static function contains(array $ring, float $lat, float $lng): bool
{
$n = count($ring);
if ($n < 3) {
return false;
}
// Quick reject: outside the bounding box means outside the polygon.
$lngs = array_column($ring, 0);
$lats = array_column($ring, 1);
if ($lng < min($lngs) || $lng > max($lngs) || $lat < min($lats) || $lat > max($lats)) {
return false;
}
// Ray casting: count how many edges a ray going east from the point crosses.
$inside = false;
for ($i = 0, $j = $n - 1; $i < $n; $j = $i++) {
[$xi, $yi] = $ring[$i];
[$xj, $yj] = $ring[$j];
$crosses = ($yi > $lat) !== ($yj > $lat)
&& $lng < ($xj - $xi) * ($lat - $yi) / ($yj - $yi) + $xi;
if ($crosses) {
$inside = ! $inside;
}
}
return $inside;
}
/** First area (GeoJSON Polygon geometry, outer ring only) that covers the point. */
public static function firstMatch(iterable $areas, float $lat, float $lng): mixed
{
foreach ($areas as $area) {
$ring = $area->geometry['coordinates'][0] ?? [];
if (self::contains($ring, $lat, $lng)) {
return $area;
}
}
return null;
}
}
How it works
What it solves
Home services, delivery and local booking products often need to answer "do we serve this address?" The answer is a polygon drawn by an admin, not a simple radius. You can check it in plain PHP without a spatial database extension.
How it works
contains() expects the outer ring of a GeoJSON polygon, whose points are ordered longitude first and then latitude. It first computes the bounding box and rejects any point outside it, which handles most misses with four comparisons.
For points inside the box it runs the classic ray-casting test. Imagine a ray travelling east from the point. Each polygon edge that straddles the point's latitude and lies to its east flips an inside flag. An odd number of crossings means the point is inside. The code walks edges with the $j = $i++ idiom so each vertex pairs with its predecessor, including the closing edge from the last vertex to the first.
firstMatch() takes a collection of stored areas, each with a GeoJSON geometry column cast to an array, and returns the first one that covers the point. This is useful to attach a zone, price or provider team to a booking.
Adapting it
- Support holes by also testing the other rings and negating the result.
- Add a pre-filter on a stored bounding box column so a database query narrows candidate areas first.
- Geocode the customer's address on the server and pass the coordinates here.
Pitfalls
- Swapping latitude and longitude is the most common bug; GeoJSON is [lng, lat].
- Points exactly on an edge may land on either side; treat the boundary as undefined.
- Polygons that cross the 180th meridian need special handling.
How to use it
- Store each area as a
geometryJSON column (cast toarray) holding a GeoJSON Polygon. - Put
Geofence.phpinapp/Geo/. - Check a single area:
Geofence::contains($area->geometry['coordinates'][0], 25.2048, 55.2708); - Find the covering zone:
$zone = Geofence::firstMatch(ServiceArea::where('active', true)->get(), $lat, $lng); - Reject the booking with a clear message when the result is null.
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.