factory/geo-area (0.2.0)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/geo-area:0.2.0About this package
factory/geo-area
Do we serve this address, and on what terms?
Polygon service areas with per-zone commercial rules. Reusable engine 8 — built project-agnostic, composed by any product that has a coverage map: couriers, rentals, freight, clinics, field services.
use Factory\GeoArea\Geometry\Point;
use Factory\GeoArea\ServiceAreas;
$areas = app(ServiceAreas::class);
$here = Point::make(lat: 4.05, lng: 9.77);
if (! $areas->serves($here)) {
return response()->json(['served' => false], 422);
}
$rules = $areas->rulesFor($here); // fee_minor, min_order_minor, eta_minutes, extra…
Drawing one:
use Factory\GeoArea\Models\ServiceArea;
use Factory\GeoArea\Geometry\Polygon;
(new ServiceArea([
'slug' => 'downtown', 'name' => 'Downtown', 'kind' => 'delivery', 'priority' => 10,
'rules' => ['fee_minor' => 1500, 'min_order_minor' => 5000, 'eta_minutes' => 35],
]))->setPolygon(Polygon::fromGeoJson($geojsonCoordinates))->save();
The decisions worth knowing
Fail closed. No area covering the point means not served — never a default zone, never the nearest one. An engine that invents a fallback promises delivery to an address nobody drives to, and the product that composed it never sees the decision being made. Serve everywhere by drawing an area covering everywhere, which is then a row somebody can point at.
Overlap is normal; ambiguity is not. A surcharge zone drawn on top of a
city-wide zone is how you say "here it costs more". covering() returns every
match ordered by priority desc then slug asc, and locate() takes the first
— so the same point resolves to the same zone on every request, whatever order
the database felt like returning rows in.
[lng, lat] in storage, (lat, lng) in code. GeoJSON's order is the storage
order; every mapping SDK's order is the argument order. A pair of raw floats
passed one function further than intended silently becomes a point in the wrong
hemisphere — Douala (4.05, 9.77) read backwards lands in the Gulf of Guinea,
which is a plausible coordinate and therefore never crashes. Point is never
built from an ambiguous pair: Point::make(lat, lng) and Point::fromGeoJson([lng, lat])
are named for the order they expect.
A shared edge belongs to exactly one zone. The crossing test is half-open, so a point standing on the border between two adjacent zones is inside the polygon to its right and outside the one to its left. Arbitrary, deterministic, tested — because "on the edge counts as inside" means both neighbours claim the customer and the fee for that address changes between two identical requests.
Holes are how you say "not here". Ring 0 is the outer boundary; every later ring is subtracted. The zone that covers a quarter except the port perimeter is one row, not a second exclusion area at higher priority whose overlaps then need their own tie-break.
The antimeridian is refused, not handled. A ring spanning more than 180° of longitude is either wrapped across ±180 or has swapped coordinates, and both make the flat-plane ray cast wrong. No fleet product crosses the date line; the day one does, this throws and somebody implements it deliberately.
Flat plane, city scale. Ignoring the earth's curvature is far below the error in a polygon somebody drew by hand. It would be wrong for a country-sized ring — which is why this is a service area and never a border.
Money is minor units and whole numbers. A 1500.0 in a rules blob is how a fee
becomes 1499.9999 two JSON round trips later. Fractional money throws at load,
where a human is watching, rather than at settlement, where nobody is.
No spatial extension. MariaDB in production and SQLite in tests, and a package that behaves differently in the two is a package nobody can test. The database narrows on four bounding-box columns (maintained on every save — a box that disagrees with its ring is an area the resolver silently skips) and PHP decides containment exactly.
No routes, no config key, no middleware alias. Thirteen providers write into
each of those namespaces and every one is last-writer-wins and silent. Whatever
/api/areas this package shipped would be a URI no product could ever use for its
own, taken without anyone deciding. Products expose the resolver on routes they
own. (DoctrineTest asserts this, so it stays true.)
The table is factory_service_areas, not service_areas, for the same reason.
Tenancy
ServiceArea uses BelongsToProduct: rows are scoped to the resolved product and
stamped on insert, and an HTTP request with no product bound gets an empty result
set rather than another product's zones.
Tests
composer install && vendor/bin/phpunit # 24 tests
Dependencies
Dependencies
| ID | Version |
|---|---|
| factory/kernel | ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 |
| illuminate/database | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/support | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| php | ^8.2 |
Development dependencies
| ID | Version |
|---|---|
| orchestra/testbench | ^8.0 || ^9.0 || ^10.0 || ^11.0 |
| phpunit/phpunit | ^10.1 || ^11.0 || ^12.0 |