factory/dispatch (0.2.1)

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

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/dispatch:0.2.1

About this package

SaaS Factory dispatch: geohash supply index, nearest-available search without a spatial extension, and offer/accept/reject windows that expire. Fail-closed — an expired offer cannot be accepted and a job is claimed exactly once.

factory/dispatch

Who is near this job, and who ends up with it.

A geohash supply index with nearest-available search, and offer/accept/reject windows that expire on their own. Reusable engine 9 — built project-agnostic and composed by any product that hands work to whoever is closest: couriers, freight, field service, roadside, home visits.

use Factory\Dispatch\Dispatcher;
use Factory\GeoArea\Geometry\Point;

$dispatch = app(Dispatcher::class);
$pickup   = Point::make(lat: 4.0511, lng: 9.7679);

// Who's around?
$riders = $dispatch->nearest($pickup, metres: 3000, kind: 'bike');
$riders->first()->distance_metres;   // real metres, great-circle

// Offer it, one at a time, skipping anyone already asked.
$rider = $dispatch->nextCandidate('job-91', $pickup, metres: 3000);
$dispatch->offer('job-91', $rider->ref, windowSeconds: 45);

// Later, from the courier's app:
$dispatch->accept('job-91', $rider->ref);   // throws if lapsed or already claimed
$dispatch->reject('job-91', $rider->ref);

Keeping the index warm is the product's job — a heartbeat endpoint that does $unit->moveTo($point)->save().

The decisions worth knowing

A prefix is not proximity. Two couriers 50 m apart can sit either side of a geohash cell boundary and share no prefix at all. The failure that causes — the nearest driver was never offered the job — is invisible from the outside, because nothing errors and somebody does eventually get it. So every search reads the cell and its eight neighbours, and haversine decides the order. The geohash narrows; it never decides. (There is a test that plants a unit across a real cell boundary; deleting the neighbour sweep turns it red.)

Great-circle, not flat plane. factory/geo-area uses a flat plane on purpose — at city scale it is below the error of a hand-drawn polygon. Dispatch is the one place it is not good enough: a list sorted by a plane approximation puts the wrong person first often enough to notice, and "nearest" is the entire promise.

Expiry is derived from the clock, never stored. Offer::state() reads expires_at and reports Expired whether or not a sweeper has run. A stored expired written by a cron means every minute the cron is late is a minute a courier can accept a job that was already reassigned — and that only ever happens in production.

A job is claimed exactly once. accept() locks every offer for the job, not just the caller's, because the race is two different couriers accepting at the same moment — a row lock on one offer would not see it. Offering a job that is already claimed throws: it would send a second driver on a trip that does not exist.

A lapsed offer can still be rejected. The courier pressing "no" after the window closed is telling us something true, and refusing the press leaves the row saying offered forever.

ref is opaque, and so is job_ref. No foreign keys to the product's own tables. The thing being dispatched is a booking in one product and a delivery in another; an engine that constrains to one of them is an engine only one product can compose.

No spatial extension. MariaDB in production and SQLite in tests; a package that behaves differently in the two is a package nobody can test. Prefix LIKE on an indexed column works identically on both.

No routes, no config key, no middleware alias. Accepting a job is the product's own endpoint with the product's own authorization on it. This engine has no idea who a courier is, so an accept route it shipped would be an unauthenticated one. Tables are factory_-prefixed for the same family of reason. (DoctrineTest asserts all of it, so it stays true.)

Tenancy

Both models use BelongsToProduct: rows are scoped to the resolved product and stamped on insert. Two products can use the same job_ref without colliding.

Tests

composer install && vendor/bin/phpunit     # 26 tests

Dependencies

Dependencies

ID Version
factory/geo-area ^0.1 || ^0.2
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:39 +00:00
32
proprietary
26 KiB
Assets (1)
Versions (2) View all
0.2.1 2026-08-16
0.2.0 2026-08-06