factory/geo-area (0.2.1)

Published 2026-08-16 13:47:44 +00:00 by bmiadmin

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/geo-area:0.2.1

About this package

SaaS Factory service areas: polygon coverage with holes, deterministic overlap resolution, a bounding-box prefilter that needs no spatial extension, and per-zone rules. Fail-closed — outside every area is not serviceable, and there is no default area.

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 || ^0.10
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
Details
Composer
2026-08-16 13:47:44 +00:00
33
proprietary
28 KiB
Assets (1)
Versions (2) View all
0.2.1 2026-08-16
0.2.0 2026-08-06