Delivery carriers
A carrier comes to a WebX UI shop as a satellite package, delivery-<carrier>, next to webx-ui/module-delivery. The base keeps everything the checkout reads — towns, points, streets, prices of the shop's own methods — in the site's own tables; the satellite only answers: it gives the carrier's directories page by page when the sync asks, prices a parcel, makes a waybill and says where a parcel is. The checkout never waits for a carrier's API except for a price.
This guide writes one, «Carrier A», and walks it through on the fake carrier the base ships for its tests — WebxUi\Delivery\Testing\FakeCarrier, the same «Carrier A» the code below describes.
The package
delivery-carrier-a/
src/CarrierA.php # Carrier + SyncsDirectories
src/Types/{BranchType,LockerType,CourierType}.php
src/CarrierAServiceProvider.php
database/migrations/ # a method per type, switched off
lang/ # webx-delivery-carrier-a: the carrier's own words only
tests/CarrierContractTest.php # on recorded answers of the API
tests/answers/ # the recorded answers
AGENTS.md, README.mdIt requires webx-ui/module-delivery. The carrier's name lives in the satellite only: the base, its tests and its demo say «carrier A», the country XX and the phone +800 1234 5678.
The carrier
use WebxUi\Delivery\ConnectionResult;
use WebxUi\Delivery\Contracts\Carrier;
use WebxUi\Delivery\Contracts\SyncsDirectories;
final class CarrierA implements Carrier, SyncsDirectories
{
public function __construct(private readonly Api $api) {}
public function key(): string { return 'carrier-a'; }
public function title(string $locale): string { return 'Carrier A'; }
public function icon(): ?string { return null; }
/** ISO 3166-1 codes the carrier works in. */
public function countries(): array { return ['XX']; }
/** Fields of the panel: the sender for waybills, defaults. */
public function settings(): array { return [['name' => 'sender_phone', 'type' => 'text']]; }
/** Secret fields: stored encrypted, shown as «set», never read back, never given to an agent. */
public function credentials(): array { return [['name' => 'api_key', 'type' => 'secret']]; }
public function testConnection(array $credentials): ConnectionResult
{
return $this->api->ping($credentials['api_key'] ?? '')
? ConnectionResult::ok()
: ConnectionResult::failed('The key was refused.');
}
// directories() and pull() — below
}The address of the API is the satellite's config, not something a person types in the panel.
The sender from the carrier's account
A carrier whose API knows the shop's senders lets the panel offer them instead of a text field: it implements ProvidesSettingOptions and marks the fields options: 'carrier'. depends names the fields the options wait for — a new sender empties its contact — and search asks as the admin types, for lists of hundreds.
use WebxUi\Delivery\Contracts\ProvidesSettingOptions;
use WebxUi\Delivery\Settings\SettingOption;
final class CarrierA implements Carrier, ProvidesSettingOptions, SyncsDirectories
{
public function settings(): array
{
return [
['name' => 'sender', 'type' => 'select', 'options' => 'carrier'],
['name' => 'contact', 'type' => 'select', 'options' => 'carrier', 'depends' => ['sender']],
];
}
/** @return list<SettingOption> at most 50 */
public function settingOptions(string $field, array $values, array $credentials, ?string $query = null): array
{
return match ($field) {
'sender' => $this->api->senders($credentials['api_key']),
'contact' => $this->api->contacts($credentials['api_key'], $values['sender']),
};
}
}The base asks with the stored key — none set, it does not ask — keeps the options ten minutes, asks again past the cache on «Refresh lists» and on every save, and takes only a value the list has. The label of the chosen value is kept in settings._labels, so the dialog shows «Sender A» when the carrier is down; a value gone from the account is said, never wiped. Throw CarrierUnavailable when the API does not answer — within five seconds, a person is waiting. The agent's door is the same: delivery_carriers_options and delivery_carriers_update.
Directories
A carrier that keeps its towns, points and streets on the site implements SyncsDirectories: which directories it has, on which cron, and one page of a directory from a cursor.
use WebxUi\Delivery\Sync\{Directory, DirectorySchedule, Page, PointRecord, StreetRecord};
use WebxUi\Geography\Matching\LocalityRecord;
public function directories(): array
{
return [
new DirectorySchedule(Directory::Localities, '30 3 * * *'),
new DirectorySchedule(Directory::Points, '30 3 * * *'),
// Only a carrier that goes to doors: weekly, pages of 2 000 narrow rows.
new DirectorySchedule(Directory::Streets, '0 4 * * 0', pageSize: 2000),
];
}
public function pull(Directory $directory, ?string $cursor): Page
{
$answer = $this->api->page($directory->value, $cursor); // throws CarrierUnavailable on a 5xx
return new Page(
array_map(fn (array $row) => match ($directory) {
Directory::Localities => new LocalityRecord(externalId: $row['id'], country: 'XX', name: $row['name'], kind: 'town', region: $row['region']),
Directory::Points => new PointRecord(externalId: $row['id'], kind: 'branch', localityExternalId: $row['town'], address: $row['address']),
Directory::Streets => new StreetRecord(externalId: $row['id'], localityExternalId: $row['town'], name: $row['name']),
}, $answer['items']),
$answer['next'], // null on the last page
);
}What the base does with the pages:
- Towns are matched with the shared geography — by the country, the region, the type and the name key, whatever the apostrophes, dashes or letter case. A town an administrator made by hand is bound to, not made twice and not renamed; what no rule places goes to the binding queue of the panel.
- Every page is written in one transaction, and a page that failed is pulled again from the same cursor:
pull()must give the same records for the same cursor. - What a complete pass did not see is removed — unless it would be more than 10 % of the carrier's records (
webx-delivery.sync.max_removal_share): then the run is held until a person confirms it in the panel. An empty or cut-off answer of an API never takes a network away. - A run that fails in the middle removes nothing; the last complete directory stays in force.
Throw WebxUi\Delivery\Sync\CarrierUnavailable for an API that did not answer: the run tries the page again after a pause, the panel shows the failure in words.
Records a page leaves out go to the third argument of Page — ['your_reason' => [ids…]] — and the carriers screen counts them under the directory. Say the reasons in words with NamesSkipReasons (skipReason($reason, $count, $locale), from your own dictionary); a reason nobody names reads «Skipped, for another reason: 12», never as its key.
If your point names already carry the street («Branch 21: 8 Main St.»), implement NamesPointLines: pointLine($title, $address) is the line under the name in the checkout's list (and in your own pickers through Points::line()) — return null for none. Without it the line is the address.
Method types
A type is what a shop switches on as a method: «Carrier A branch», «Carrier A courier». It extends AbstractMethodType, which brings the method's price and the check of a point or an address.
use WebxUi\Delivery\{AbstractMethodType, Asks, DeliveryContext, Method, Quote};
final class BranchType extends AbstractMethodType
{
public function key(): string { return 'carrier-a.branch'; } // <carrier>.<kind>
public function asks(): Asks { return Asks::Point; }
public function pointKinds(): array { return ['branch']; }
public function quote(Method $method, DeliveryContext $context): Quote
{
// A price set on the method wins; otherwise the buyer pays the carrier on receipt.
return $method->price === null ? Quote::carrierRates() : Quote::fixed($method->price);
}
}quote() is the only place a type may call the carrier's API — the base caches the answer for ten minutes and gives it two seconds; late or failed, the method shows «By carrier rates». A courier type asks Asks::Address: its street comes only from the carrier's streets directory, and a town the carrier gives no streets for shows the method switched off with a hint.
A type that implements CreatesWaybills makes waybills from the order's card; one that implements TracksShipments is asked where parcels are every 30 minutes, its states translated into the base's ShipmentStatus — received sets the order's milestone «received», once.
The waybill gets the recipient's first and last name apart, as the order keeps them, and carrierLocality — the carrier's own id of the town. What the carrier asks beyond that — weight, seats, what is inside — the type declares with DescribesWaybill: waybillFields() in the form of the settings (filled from the carrier's setting of the same name, or default) reach it in WaybillRequest::$extra; supportsCod(): false hides the cash on delivery in the dialog and refuses it from an agent.
Registering
use WebxUi\Delivery\{Carriers, MethodTypes};
public function boot(): void
{
$this->callAfterResolving(Carriers::class, fn (Carriers $carriers) => $carriers->register(CarrierA::class));
$this->callAfterResolving(MethodTypes::class, function (MethodTypes $types): void {
$types->register(BranchType::class);
$types->register(LockerType::class);
$types->register(CourierType::class);
});
}The satellite's migration makes a method for each type with enabled = false: the shop switches on the ones it wants, in the panel's «Methods».
The contract test
The base ships the contract every satellite passes, WebxUi\Delivery\Testing\CarrierContract. Run it on answers of the API recorded once — never on the live API:
use PHPUnit\Framework\TestCase;
use WebxUi\Delivery\Contracts\Carrier;
use WebxUi\Delivery\Testing\CarrierContract;
final class CarrierContractTest extends TestCase
{
use CarrierContract;
protected function carrier(): Carrier
{
return new CarrierA(new RecordedApi(__DIR__.'/answers'));
}
protected function methodTypes(): array
{
return [new BranchType, new LockerType, new CourierType];
}
}It checks the key and the countries; walks every directory from the first page to a null cursor, pulling each page twice and asking for the same records; the record classes, ids unique in a pass, names the normalizer keeps something of, a type of locality the geography knows; points and streets that point at the carrier's own towns; and types named after the carrier, a point type with its kinds. The base runs the same contract on «Carrier A» — php/packages/module-delivery/tests/CarrierContractTest.php.
Next to it a satellite keeps a PerformanceTest: a page of 500 records (2 000 streets) writes in a constant number of queries.
Walk it through on carrier A
The fake carrier can run on a development site, so the whole road of a carrier is there to click through before a line of a satellite is written:
- In the site's
.env:WEBX_DELIVERY_FAKE_CARRIER=true. Never in production — its waybills go nowhere. php artisan webx:demo --module=delivery— the shop's own towns, points and methods, and carrier A synced with its three methods. Or by hand: switch carrier A on in «Carriers» and press «Run», orphp artisan webx:delivery:sync carrier-a.- In «Carriers» — each directory with its last success and counters; in «Geography» — its towns, «Region A» and «Region B» the same as the ones made by hand; in «Binding queue» — what no rule placed.
- In the checkout — a town of carrier A, «Carrier A branch» with its list of branches and a search, «Carrier A courier» with a street from its directory only; Town 0 shows the courier switched off — the carrier gives no streets there.
- With
webx-ui/module-orders— an order with a branch, then «Create waybill» on the order's tab «Delivery»: a numberA-0001and a label.
Read more
- Delivery — the shop's own methods, the panel, the agents' tools.
- The delivery spec, §7 (directories), §8 (streets), §11 (waybills), §13 (the satellite) —
WEBX_UI_MODULE_DELIVERY.md.