Omnibus is one PHP contract for parcel carriers. A shop asks every carrier the same questions -
what does this shipment cost, book it and give me the label, where is this parcel, which pickup
points are near this address, print the label again, cancel it - and gets the same answers back:
Rate, Label, Tracking, PickupPoint. Each carrier is a package of its own (omnibus/ups,
omnibus/mondial-relay...) that turns those questions into the carrier's API calls.
$gateway = $registry->get('relais');
$gateway->rate($shipment); // Rate[], cheapest first
$gateway->ship($shipment); // Label: tracking number, the document
$gateway->track('12345678'); // Tracking: status, events oldest first
$gateway->pickupPoints($address, 10); // PickupPoint[], nearest first
$gateway->slip('12345678'); // Label, printed again
$gateway->cancel('12345678'); // true when the carrier voided it
The design is Payum's: a carrier's factory fills a configuration with its name, its options,
its API client and one action per request it answers; the gateway it builds runs the action
that supports each request; a registry holds the shop's gateways by name. The core needs no
framework: it requires symfony/http-client-contracts and nothing else. A carrier that calls an API
requires symfony/http-client, a library that stands alone. A Symfony bundle is included for
applications that want the wiring done (Symfony).
Published as omnisense/omnibus, formerly glitchr/omnibus: the package replaces the old name,
so a composer.json that still requires glitchr/omnibus resolves to this package and never
installs both. The namespace is Omnibus\; carriers keep the omnibus/ vendor and the
Omnibus\<Carrier>\ namespace.
When to use it
- A shop or a back office that ships with more than one carrier, or wants to be able to change carrier without rewriting its checkout and its order screens.
- Prices that come from a carrier's API for some carriers and from the shop's own grid for others
(the
ratesoption, Configuration). - Click and collect, or parcels the shop hands over itself:
omnibus/offlineanswers the same contract without any carrier.
It is not a multi-carrier aggregator service (no account, no billing of its own): each gateway calls its carrier with the shop's own contract and credentials. It does not store anything either: saving the tracking number and the label is the application's job.
Carriers
Twenty-three packages. "Configured rates" means the carrier has no rating action and prices come
only from the rates option. The capabilities are what each gateway's supports() answers with
its required options set (probed in the harness on 2026-10-25); "with X" means the
request is supported only once option X is set.
| Package | Factory name | Rate | Ship | Track | Pickup points | Slip | Cancel |
|---|---|---|---|---|---|---|---|
omnibus/offline |
offline |
configured | yes | yes (status unknown) | yes (configured) | - | - |
omnibus/mondial-relay |
mondial_relay |
configured | yes | yes | yes | yes | - |
omnibus/colissimo |
colissimo |
configured | - | - | - | - | - |
omnibus/chronopost |
chronopost |
yes | yes | yes | yes | - | yes |
omnibus/ups |
ups |
yes | yes | yes | yes | - | - |
omnibus/fedex |
fedex |
yes | yes | yes | yes | - | - |
omnibus/dhl |
dhl |
yes | yes | yes | with location_api_key |
- | - |
omnibus/tnt |
tnt |
yes | yes | yes | - | - | - |
omnibus/gls |
gls |
configured | yes | yes | - | - | yes |
omnibus/db-schenker |
db_schenker |
configured | with api_key and account_number |
yes | - | - | - |
omnibus/usps |
usps |
yes | with crid, mid, account |
yes | yes | - | - |
omnibus/royal-mail |
royal_mail |
configured | yes | with tracking_client_id and tracking_client_secret |
- | - | - |
omnibus/canada-post |
canada_post |
yes | yes | yes | yes | - | - |
omnibus/purolator |
purolator |
yes | yes | yes | yes | - | yes |
omnibus/canpar |
canpar |
yes | yes | yes | - | yes | yes |
omnibus/auspost |
auspost |
yes | yes | yes | - | yes | yes |
omnibus/aramex |
aramex |
yes | yes | yes | yes | - | - |
omnibus/bluedart |
bluedart |
yes | yes | yes | - | - | yes |
omnibus/dtdc |
dtdc |
configured | yes | with tracking_username and tracking_password |
- | yes | yes |
omnibus/sf-express |
sf_express |
configured | yes | yes | - | - | yes |
omnibus/jdl-express |
jdl_express |
configured | yes | yes | - | - | yes |
omnibus/zto-express |
zto_express |
configured | yes | yes | - | - | yes |
omnibus/amazon |
amazon |
yes | yes | yes | - | - | yes |
Every carrier also accepts the rates option: set, it replaces the carrier's rating API
(Configuration). No carrier answers the Paperless request
(customs documents sent electronically) yet: the request is part of the contract, and
supports(Paperless::class) is false everywhere. Each carrier's own manual lists its options,
credentials and behaviour.
How this manual is organised
- Installation: requirements, a first script in plain PHP, a carrier's API.
- Symfony and Laravel: wiring the registry in a framework.
- Concepts: gateways, factories, the registry, requests and actions, every model.
- Usage: rates, shipments and labels, tracking, pickup points, slips, cancellations and customs documents.
- Configuration reference: the registry's configuration, the
ratesoption, every carrier's options. - Errors: the three exceptions and when each is thrown.
- Testing: testing an application that ships with Omnibus.
- The Docker harness: every carrier installed, a console to call them.
- Writing a carrier, and its conformance suite.
- Verified, and not.
Proposer une modification docs/index.md
Installation
composer require omnisense/omnibus omnibus/offline # no carrier: the shop's own rates
composer require omnibus/mondial-relay omnibus/ups ... # a carrier's API, one package each
Requirements
- PHP 8.2 or later.
omnisense/omnibusrequiressymfony/http-client-contracts(^3.0) and nothing else.- A carrier that calls an API requires
symfony/http-client(^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework.omnibus/offlineandomnibus/colissimocall nothing and require neither. - No
ext-soap: the SOAP carriers (Chronopost, Purolator, Canpar) write and read their XML themselves.omnibus/mondial-relayrequiresext-simplexml, enabled in most PHP builds.
Omnibus runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).
Plain PHP
A whole script, with no carrier and no network: the shop's own prices, a tracking number typed at the post office counter, click and collect at the shop.
<?php // bare.php
require __DIR__.'/vendor/autoload.php';
use Omnibus\Model\Address;
use Omnibus\Model\Parcel;
use Omnibus\Model\Shipment;
use Omnibus\Offline\OfflineGatewayFactory;
use Omnibus\Registry;
$registry = new Registry([new OfflineGatewayFactory()], [
'boutique' => ['factory' => 'offline', 'options' => [
// The shop's own prices, by weight (grams => cents)
'rates' => [
['service' => 'courier', 'label' => 'Coursier', 'currency' => 'EUR', 'bands' => [1000 => 690, 30000 => 1290]],
['service' => 'pickup', 'label' => 'Retrait en boutique', 'to_pickup_point' => true, 'bands' => [30000 => 0]],
],
'tracking_url' => 'https://www.laposte.fr/outils/suivre-vos-envois?code={number}',
'pickup_points' => [['id' => 'shop', 'name' => 'La boutique', 'street' => ['1 rue de Rivoli'], 'postcode' => '75001', 'city' => 'Paris', 'country' => 'FR']],
]],
]);
$gateway = $registry->get('boutique');
$shipment = new Shipment(
new Address('La boutique', ['1 rue de Rivoli'], '75001', 'Paris', 'FR'),
new Address('Camille Durand', ['12 rue des Juifs'], '67000', 'Strasbourg', 'FR'),
[new Parcel(500)], // grams
options: ['tracking_number' => '6A12345678901'], // typed by hand at the counter
);
foreach ($gateway->rate($shipment) as $rate) {
printf("%-20s %5.2f %s\n", $rate->label, $rate->amount / 100, $rate->currency);
}
$label = $gateway->ship($shipment);
echo "\n", $label->trackingNumber, ' ', $label->trackingUrl, "\n";
echo $gateway->track($label->trackingNumber)->status->value, "\n";
foreach ($gateway->pickupPoints($shipment->recipient) as $point) {
echo $point->id, ': ', $point->name, ', ', $point->address->postcode, ' ', $point->address->city, "\n";
}
$ php bare.php
Retrait en boutique 0.00 EUR
Coursier 6.90 EUR
6A12345678901 https://www.laposte.fr/outils/suivre-vos-envois?code=6A12345678901
unknown
shop: La boutique, 75001 Paris
(run on 2026-10-25 in the harness, PHP 8.4; first run on 2026-10-05 in an empty
directory after composer require omnisense/omnibus omnibus/offline: three packages installed,
symfony/http-client-contracts the only one that is not Omnibus's)
No key, no network: the shop hands the parcel over itself, so its tracking is the number typed and
its state is not known to anyone (unknown). The pieces:
- a factory per carrier package (
OfflineGatewayFactory,MondialRelayGatewayFactory,UpsGatewayFactory...). One that calls an API takes the HTTP client to call with - the application's, aMockHttpClientin a test - and with none given makes its own (HttpClient::create()); - the registry, built from the factories and the gateways' options, by name:
get(), andcreate($name, $overrides)for keys typed in a back office; - the gateways it gives:
rate(),ship(),track(),pickupPoints(), and for the carriers that do themslip()andcancel()-supports()says beforehand what a gateway does.
No class of a framework is loaded on the way: Tests/BareTest.php checks it in a process of its
own, and so does docker compose run --rm omnibus bare (harness).
A carrier's API
use Omnibus\Ups\UpsGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;
$ups = (new UpsGatewayFactory(HttpClient::create()))->create([
'client_id' => getenv('UPS_CLIENT_ID'),
'client_secret' => getenv('UPS_CLIENT_SECRET'),
'account_number' => getenv('UPS_ACCOUNT'),
'sandbox' => true, // the Customer Integration Environment
]);
$rates = $ups->rate($shipment); // Rate[], cheapest first
$label = $ups->ship($shipment); // tracking number, the label's content and format
$ups->track($label->trackingNumber); // status, events
$ups->pickupPoints($shipment->recipient, 10); // Access Points near an address
Building a gateway calls nothing: a carrier's token is asked for on the first call, and a missing
required option is reported by create() as an InvalidConfigException naming it
(The "ups" gateway needs: client_secret, account_number.).
Two carriers publish test identifiers for everyone, usable without a contract: Mondial Relay's test
brand (['sandbox' => true] fills enseigne and private_key with BDTEST13 and PrivateK) and Chronopost's test
account (ChronopostGatewayFactory::TEST_ACCOUNT, ::TEST_PASSWORD). They are test identifiers,
not secrets.
In a framework
- Symfony:
Omnibus\Bridge\Symfony\OmnibusBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnibus.yamland makes each gateway injectable by its name: Symfony. - Laravel: no bridge package is needed; a service provider builds the registry once: Laravel.
- Any other: build the
Registryonce, where the framework builds its services (a container definition, a factory), as the script above does.
Proposer une modification docs/installation.md
Symfony
Omnibus runs without a framework (Installation); in a Symfony application its
bundle does the wiring. The bundle's components - symfony/config, symfony/dependency-injection,
symfony/http-kernel (6.4, 7 or 8) - are not required by omnisense/omnibus: the application has
them, and nothing of them is loaded outside Symfony.
Registering the bundle
There is no Flex recipe: add the bundle by hand.
// config/bundles.php
return [
// ...
Omnibus\Bridge\Symfony\OmnibusBundle::class => ['all' => true],
];
Configuration
# config/packages/omnibus.yaml
omnibus:
gateways: # by name: a factory and its options
relais:
factory: mondial_relay
options: { enseigne: '%env(MONDIAL_RELAY_ENSEIGNE)%', private_key: '%env(MONDIAL_RELAY_PRIVATE_KEY)%' }
retrait:
factory: offline
options:
rates:
- { service: pickup, label: 'Retrait en boutique', to_pickup_point: true, bands: { 30000: 0 } }
# .env.local, or bin/console secrets:set
MONDIAL_RELAY_ENSEIGNE=...
MONDIAL_RELAY_PRIVATE_KEY=...
The whole tree:
| Key | Type | Default | |
|---|---|---|---|
omnibus.gateways |
map, keyed by the gateway's name | {} |
the shop's carriers |
omnibus.gateways.<name>.factory |
string, required, not empty | - | a factory's name: offline, mondial_relay, ups... (carriers), or an application's own factory's getName() |
omnibus.gateways.<name>.options |
any value (a map) | [] |
handed as they are to the factory's create(): the carrier's options (Configuration) |
The options are not validated when the container compiles - the tree accepts any value - but
when the gateway is built: a required option missing or empty is an InvalidConfigException
naming it, the first time the gateway is asked for.
Services
Every omnibus/* carrier package installed has its factory registered as a service, built on the
application's http_client service (or with none, when the application has no HTTP client
service: the factory then makes its own). The bundle knows the 23 carriers of the
table and registers those whose class exists.
| Service id | Class | Autowiring | |
|---|---|---|---|
Omnibus\Registry |
Registry |
Registry $registry |
every configured gateway by name: get(), has(), names(), options(), all(), create() with other options. Public. |
omnibus.gateway.<name> |
GatewayInterface |
GatewayInterface $<name> |
one gateway, built by Registry::get('<name>') the first time it is injected |
Omnibus\<Carrier>\<Carrier>GatewayFactory |
the carrier's factory | by its class | tagged omnibus.gateway_factory |
use Omnibus\GatewayInterface;
final class ShippingController
{
public function __construct(private readonly GatewayInterface $relais) // the "relais" gateway
{
}
}
The argument's name selects the gateway: GatewayInterface $relais is omnibus.gateway.relais
(Symfony's named autowiring aliases, registerAliasForArgument()). A gateway name that is not a
valid PHP variable name (mondial-relay) is still reachable by its service id or by the registry.
Nothing is built when the container compiles: a gateway is built the first time it is asked for,
and an option left empty shows only then. Keys typed in a back office rather than set in the
environment: $registry->create('relais', ['private_key' => $stored]), a gateway built afresh with
those options over the configured ones and not kept by the registry.
An application's own carrier
A class of the application that implements Omnibus\GatewayFactoryInterface (usually by extending
Omnibus\GatewayFactory) is autoconfigured: tagged omnibus.gateway_factory, it joins the
registry, and its getName() can be named as a factory:
omnibus:
gateways:
coursier:
factory: my_courier # App\Shipping\MyCourierGatewayFactory::getName()
The bundle registers no command, event, Twig function or route: the gateways are services, used from the application's controllers, message handlers and commands.
Proposer une modification docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnibus provider: none is
needed. The registry is plain PHP; a service provider of the application builds it once, from a
config file, with a symfony/http-client client (every carrier that calls an API requires that
package; Laravel's own HTTP client is Guzzle's, which the carriers do not take).
// config/omnibus.php
return [
'gateways' => [
'relais' => ['factory' => 'mondial_relay', 'options' => [
'enseigne' => env('MONDIAL_RELAY_ENSEIGNE'),
'private_key' => env('MONDIAL_RELAY_PRIVATE_KEY'),
]],
'retrait' => ['factory' => 'offline'],
],
];
// app/Providers/OmnibusServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Omnibus\MondialRelay\MondialRelayGatewayFactory;
use Omnibus\Offline\OfflineGatewayFactory;
use Omnibus\Registry;
use Symfony\Component\HttpClient\HttpClient;
final class OmnibusServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, function (): Registry {
$http = HttpClient::create();
return new Registry([
new OfflineGatewayFactory(),
new MondialRelayGatewayFactory($http),
], config('omnibus.gateways'));
});
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the
registry where it is needed:
use Omnibus\Registry;
final class CheckoutController
{
public function rates(Registry $registry)
{
$rates = $registry->get('relais')->rate($shipment);
// ...
}
}
List in the provider every carrier factory the application uses: unlike the Symfony bundle, a
hand-built registry knows only the factories it is given. Configuration caching
(php artisan config:cache) keeps working: the options are plain arrays read through config().
This page was written from the registry's API; it was not run in a Laravel application.
Proposer une modification docs/laravel.md
Concepts
Gateway
Omnibus\GatewayInterface is one carrier, configured. Every gateway answers the same methods; each
typed method is a shortcut for execute() with its request.
| Method | Returns | Request |
|---|---|---|
getName(): string |
the factory's name (ups, mondial_relay), also the carrier of every model it returns |
- |
getTitle(): string |
the carrier's display name (UPS, Mondial Relay) |
- |
supports(string $request): bool |
whether one of the gateway's actions answers that request class | - |
execute(Request $request): Request |
the same request, answered | any |
rate(Shipment $shipment): array |
Rate[], cheapest first |
Request\Rating |
ship(Shipment $shipment): Label |
the label of the booked shipment | Request\Shipping |
track(string $trackingNumber, string $locale = 'fr'): Tracking |
the parcel's status and events | Request\Tracking |
pickupPoints(Address $near, int $limit = 10, ?Parcel $parcel = null): array |
PickupPoint[], nearest first, at most $limit |
Request\Pickup |
slip(string $trackingNumber, string $format = Label::PDF): Label |
a label issued earlier, again | Request\GetSlip |
cancel(string $trackingNumber): bool |
true when the carrier voided the shipment |
Request\Cancel |
Omnibus\Gateway is the one implementation: a name, a title and a list of actions. execute()
hands the request to the first action whose supports() accepts it; when none does it throws
RequestNotSupportedException before anything is sent; when the action returns without setting a
result it throws a CarrierException (... left the request unanswered.). supports() probes the
actions with an instance of the request class built without its constructor, so it costs no call.
Request\Paperless (customs documents sent electronically) has no typed shortcut: it goes through
execute(), and no carrier answers it yet.
Factory and Config
Omnibus\GatewayFactoryInterface builds a carrier's gateway from options:
| Method | |
|---|---|
getName(): string |
the name gateways are configured with: offline, mondial_relay, ups... (snake_case) |
create(array $options = []): GatewayInterface |
a gateway, its options validated |
Every carrier extends Omnibus\GatewayFactory, which takes an optional
Symfony\Contracts\HttpClient\HttpClientInterface in its constructor (none: the carrier calls
HttpClient::create() itself) and does the work in create():
createConfig($options)- aConfig(anArrayObject) holding the options given, thenpopulateConfig()fills each key the options did not set with the carrier's default;- the keys of
omnibus.required_optionsare checked: one missing,''or[]is anInvalidConfigExceptionlisting every missing key; omnibus.api- the carrier's API client, often a closure of theConfig- is built;- every key starting with
omnibus.action.that is (or whose closure returns) anActionInterfacebecomes an action; a closure returningnullregisters no action, which is how a carrier makes an operation depend on an option. Actions implementingApiAwareInterfacereceive the API client; - when the
ratesoption is set, anAction\ConfiguredRatingActionis added last; - a
Gatewayis returned, named byomnibus.factory_nameand titled byomnibus.factory_title.
The reserved keys a factory sets:
| Key | |
|---|---|
omnibus.factory_name |
the gateway's name |
omnibus.factory_title |
the gateway's title |
omnibus.required_options |
list of option names that must be filled |
omnibus.api |
the API client, or a Closure(Config): object that builds it |
omnibus.action.<name> |
an ActionInterface, or a Closure(Config): ?ActionInterface |
Because the options are set before the defaults, a key such as omnibus.action.tracking given in
the options replaces the carrier's own action. Config::defaults() sets only keys not set yet,
Config::get($key, $default) reads one, Config::validateNotEmpty($keys) is step 2.
Registry
Omnibus\Registry holds the shop's gateways by name:
$registry = new Registry(
[new MondialRelayGatewayFactory($http), new OfflineGatewayFactory()], // iterable of factories, keyed by getName()
[
'relais' => ['factory' => 'mondial_relay', 'options' => [...]],
'retrait' => ['factory' => 'offline'], // options default to []
],
);
| Method | |
|---|---|
get(string $name): GatewayInterface |
the gateway, built on the first call and kept |
has(string $name): bool |
whether a gateway of that name is configured |
names(): list<string> |
the configured names, in the configuration's order |
options(string $name): array |
the options a gateway is configured with ([] for an unknown name) |
all(): array<string, GatewayInterface> |
every configured gateway, built |
create(string $name, array $overrides = []): GatewayInterface |
a gateway built afresh from its configured options with $overrides replacing keys (array_replace); not kept, get() still answers the configured one |
An unknown name (No "x" gateway; configured: relais, retrait.) or a factory not installed
(No "ups" factory for the "express" gateway; installed: mondial_relay, offline.) is an
InvalidConfigException.
Requests and actions
A request (Omnibus\Request\Request) is a question put to a gateway; the action that supports it
answers by calling setResult(). getResult() reads the answer, isAnswered() says whether there
is one (a result may legitimately be [] or false).
| Request | Constructor | Result | Typed reader |
|---|---|---|---|
Rating |
Shipment $shipment |
Rate[] |
getRates() ([] when unanswered) |
Shipping |
Shipment $shipment |
Label |
getLabel() |
Tracking |
string $trackingNumber, string $locale = 'fr' |
Model\Tracking |
getTracking() |
Pickup |
Address $near, int $limit = 10, ?Parcel $parcel = null |
PickupPoint[] |
getPoints() ([] when unanswered) |
GetSlip |
string $trackingNumber, string $format = Label::PDF |
Label |
getLabel() |
Cancel |
string $trackingNumber |
bool |
getResult() |
Paperless |
string $trackingNumber, array $documents (type => PDF content) |
bool |
getResult() |
An action (Omnibus\Action\ActionInterface) has supports(Request): bool and
execute(Request): void, which sets the result or throws a CarrierException. An action that
calls the carrier implements Action\ApiAwareInterface (setApi(object $api)); ApiAwareTrait
implements it and refuses an API client of the wrong class with an InvalidConfigException.
Models
All models are final readonly classes in Omnibus\Model, built with named arguments where
convenient. Amounts are integers in minor units (cents), weights in grams, sizes in centimetres,
countries ISO 3166-1 alpha-2, currencies ISO 4217.
Address
A postal address: the sender's, the recipient's, a pickup point's.
| Property | Type | |
|---|---|---|
name |
string |
the person's name |
street |
string[] |
one line each; most carriers take up to three |
postcode |
string |
|
city |
string |
|
country |
string |
ISO 3166-1 alpha-2 (FR, GB) |
company |
?string |
null |
email |
?string |
null |
phone |
?string |
null; carriers strip or reformat it as their API wants |
line(int $index): string returns a street line, '' when there is none.
Parcel
One box.
| Property | Type | Default | |
|---|---|---|---|
weight |
int |
- | grams |
length, width, height |
?int |
null |
centimetres |
value |
?int |
null |
declared value, minor units (insurance, customs, by carrier) |
currency |
string |
EUR |
the value's currency |
reference |
?string |
null |
the shop's reference for this box |
girth(): ?int is the longest side plus twice the two others (what size bands are built on), or
null when a dimension is missing.
Shipment
What goes where: from the sender to the recipient, at home or at a pickup point, in one or more parcels, under a carrier's service.
| Property | Type | Default | |
|---|---|---|---|
sender |
Address |
- | |
recipient |
Address |
- | |
parcels |
Parcel[] |
- | at least one: an empty list throws \InvalidArgumentException |
service |
?string |
null |
the carrier's service code (11 for UPS Standard...); null lets the carrier choose its default, and makes rating offer every service |
pickupPoint |
?string |
null |
the id of a PickupPoint the parcel goes to |
reference |
?string |
null |
the order's reference, printed on the label where the carrier allows |
options |
array<string, mixed> |
[] |
carrier-specific options (label format, insurance, signature, a tracking number typed by hand...), listed in each carrier's manual |
shippingDate |
?\DateTimeImmutable |
null |
when it is handed over; unset, most carriers send today's date, DHL, TNT and DB Schenker tomorrow's |
weight(): int sums the parcels' weights; option(string $name, mixed $default = null) reads one
option.
Rate
What a service costs for a shipment and how long it takes.
| Property | Type | Default | |
|---|---|---|---|
carrier |
string |
- | the gateway's name |
service |
string |
- | the service code, to put in Shipment::$service when booking |
label |
string |
- | the service's display name |
amount |
int |
- | minor units |
currency |
string |
EUR |
ISO 4217 |
days |
?int |
null |
transit days, when the carrier says |
toPickupPoint |
bool |
false |
the service delivers to a pickup point, not to the door |
Label
The shipping label (or slip) a carrier issued.
| Property | Type | Default | |
|---|---|---|---|
carrier |
string |
- | |
trackingNumber |
string |
- | the number to track and to keep with the order |
content |
?string |
null |
the document itself (binary), when the carrier sends it inline |
format |
string |
Label::PDF |
its media type: Label::PDF (application/pdf), Label::ZPL (application/zpl), or another one a carrier gives (image/gif for UPS) |
url |
?string |
null |
where to download the document, when the carrier hands out a link instead |
trackingUrl |
?string |
null |
the carrier's public tracking page for this parcel |
carrierReference |
?string |
null |
the carrier's own handle on the booking, when its later requests need more than the tracking number (Amazon, SF Express); opaque: keep it and pass it to track(), slip() and cancel() in place of the tracking number |
A label may have neither content nor url: omnibus/offline prints nothing, ZTO's electronic
waybill is printed by its own client.
Tracking, TrackingEvent, TrackingStatus
Tracking is where a parcel is: carrier, trackingNumber, status (TrackingStatus) and
events (TrackingEvent[], oldest first). latest(): ?TrackingEvent is the last event.
TrackingEvent: at (\DateTimeImmutable), status (TrackingStatus), description (the
carrier's text, in the locale asked for when the carrier translates), location (?string),
code (?string, the carrier's own event code, kept for whoever needs the detail).
TrackingStatus folds every carrier's statuses onto one scale:
| Case | Value | |
|---|---|---|
PENDING |
pending |
announced, not handed over yet |
IN_TRANSIT |
in_transit |
|
OUT_FOR_DELIVERY |
out_for_delivery |
|
AVAILABLE_FOR_PICKUP |
available_for_pickup |
waiting at a pickup point |
DELIVERED |
delivered |
final |
EXCEPTION |
exception |
a problem: damaged, refused, address wrong... |
RETURNED |
returned |
final |
UNKNOWN |
unknown |
the carrier said nothing that maps, or there is no carrier to ask |
isFinal(): bool is true for DELIVERED and RETURNED. A carrier's tracking status is the
status of its latest event.
PickupPoint
A relay, locker or post office a parcel can be sent to and collected from.
| Property | Type | Default | |
|---|---|---|---|
carrier |
string |
- | |
id |
string |
- | the id to put in Shipment::$pickupPoint |
name |
string |
- | |
address |
Address |
- | |
latitude, longitude |
?float |
null |
|
openingHours |
array<int, list<array{string, string}>> |
[] |
ISO weekday (1 = Monday) => list of [open, close] slots, "HH:MM" |
distance |
?int |
null |
metres from the address searched, when the carrier says |
Proposer une modification docs/concepts.md
Rates
$rates = $gateway->rate($shipment); // Rate[], cheapest first
rate() answers every service the carrier can carry the shipment with, and its price. A carrier
with a rating API asks it (UPS's Shop, FedEx's rate quotes, Chronopost's Quickcost...); a carrier
without one, or a gateway with a rates option, prices from configuration with no call.
Pricing a shipment
use Omnibus\Model\Address;
use Omnibus\Model\Parcel;
use Omnibus\Model\Shipment;
$shipment = new Shipment(
sender: new Address('La boutique', ['1 rue de Rivoli'], '75001', 'Paris', 'FR'),
recipient: new Address('Camille Durand', ['12 rue des Juifs'], '67000', 'Strasbourg', 'FR'),
parcels: [new Parcel(800), new Parcel(1500, 40, 30, 20)],
);
foreach ($gateway->rate($shipment) as $rate) {
printf(
"%s %-24s %6.2f %s%s%s\n",
$rate->service,
$rate->label,
$rate->amount / 100,
$rate->currency,
null !== $rate->days ? ", {$rate->days} days" : '',
$rate->toPickupPoint ? ', to a pickup point' : '',
);
}
Show the rates to the customer, keep the chosen Rate::$service, and book with it
(new Shipment(..., service: $rate->service), Shipments and labels).
What every rating answer keeps:
- each
Rate::$carrieris the gateway's name; - amounts are integers in minor units, in the ISO 4217 currency the carrier or the configuration gives;
- the list is sorted cheapest first;
- an empty list means the carrier serves none of its services for that shipment (too heavy, a
country not served). A carrier that could not be reached is a
CarrierException, never[].
Configured prices
A shop often has negotiated prices that no public API returns, or a carrier with no rating API at
all (Mondial Relay, Colissimo, GLS, Royal Mail...). The rates option gives the gateway a price
grid by weight:
$registry = new Registry([new MondialRelayGatewayFactory($http)], [
'relais' => ['factory' => 'mondial_relay', 'options' => [
'enseigne' => '...', 'private_key' => '...',
'rates' => [
['service' => '24R', 'label' => 'Point Relais', 'to_pickup_point' => true, 'days' => 4,
'countries' => ['FR', 'BE'], 'bands' => [500 => 490, 1000 => 590, 5000 => 890]],
['service' => 'HOM', 'label' => 'Domicile', 'bands' => [1000 => 790, 5000 => 1090]],
],
]],
]);
Each parcel is priced on its own band (the first band whose weight it does not exceed) and the
parcels' prices are added; a parcel heavier than the last band, or a recipient country not in
countries, gives no rate for that service. When Shipment::$service is set, only that service is
priced. The full grammar is in Configuration.
With rates set, a carrier that has a rating API does not register its own rating action: the
configured prices replace the API, and rate() calls nothing. With the harness's offline gateway
(bands 1000 => 690, 30000 => 1290), a shipment of 800 g and 1500 g under courier costs
690 + 1290 = 1980.
Asking for one service
Shipment::$service narrows configured rates to that service. Carriers' rating APIs differ: most
price every service whatever service says (UPS's Shop), some take it into account; each
carrier's manual says which.
When the carrier has no rates
use Omnibus\Request\Rating;
if (!$gateway->supports(Rating::class)) {
// no rating API and no `rates` option: show a flat price of the shop's, or configure `rates`
}
Calling rate() there throws RequestNotSupportedException before any call
(Errors).
Proposer une modification docs/usage-rating.md
Shipments and labels
$label = $gateway->ship($shipment); // Label: tracking number, the document
ship() books the shipment with the carrier and answers the label it issued. Booking is the one
call that commits the shop (a carrier invoices booked shipments): ask for rates and let the
customer choose before, book once the order is paid.
Booking
use Omnibus\Model\Address;
use Omnibus\Model\Parcel;
use Omnibus\Model\Shipment;
$shipment = new Shipment(
sender: new Address('Glitch Art', ['12 rue de la Paix'], '75002', 'Paris', 'FR', company: 'Glitch Art', phone: '+33102030405', email: 'shop@example.org'),
recipient: new Address('Émile Zola', ['21 bis rue de Bruxelles', 'Bâtiment B'], '75009', 'Paris', 'FR', email: 'emile@example.org', phone: '0612345678'),
parcels: [new Parcel(800, 30, 20, 10, value: 4500)],
service: $chosenRate->service, // from rate(); null: the carrier's default service
reference: 'CMD-1042', // printed on the label where the carrier allows
options: ['label_format' => 'PDF'], // carrier-specific, see the carrier's manual
);
$label = $gateway->ship($shipment);
$order->setTrackingNumber($label->trackingNumber);
$order->setTrackingUrl($label->trackingUrl);
$order->setCarrierReference($label->carrierReference); // null for most carriers
Keep carrierReference when the label has one (Amazon, SF Express): those carriers' later
requests need more than the tracking number, and track(), slip() and cancel() take it in
place of the tracking number.
Give the recipient's phone and e-mail where you have them: the carriers that take them send them on, for the delivery notices. Each carrier's manual lists what it sends, what it cuts (names of 35 characters, street lines...) and the shipment options it reads.
The document
A label carries its document in one of two ways, or not at all:
if (null !== $label->content) {
// the document itself, binary: a PDF, a ZPL file for a thermal printer, a GIF (UPS)
$extension = match ($label->format) {
Omnibus\Model\Label::PDF => 'pdf',
Omnibus\Model\Label::ZPL => 'zpl',
'image/gif' => 'gif',
default => 'bin',
};
file_put_contents("labels/{$label->trackingNumber}.{$extension}", $label->content);
} elseif (null !== $label->url) {
// a link the carrier hands out: download it while it is valid
} else {
// no document: the shop writes the address itself (offline), or the carrier's own
// client prints it (ZTO's electronic waybill)
}
Label::$format is a media type; Label::PDF (application/pdf) is the default, Label::ZPL
(application/zpl) is asked for with the carrier's label_format option where it supports it.
Keep the document with the order: a reprint (slip()) is offered by few carriers
(After shipping).
To a pickup point
$points = $gateway->pickupPoints($shipment->recipient, 5); // the customer picks one
$shipment = new Shipment($sender, $recipient, $parcels, pickupPoint: $points[0]->id);
$label = $gateway->ship($shipment);
The carrier delivers to pickupPoint, the id one of its own PickupPoints answered. Each
carrier's manual says how the id is sent (Mondial Relay takes FR-066974) and which services
deliver to points (Rate::$toPickupPoint).
Several parcels
A shipment of several parcels is booked in one call where the carrier allows it; the label then has the shipment's (master) tracking number. Carriers differ in what they do with each parcel's weight and size: see each carrier's manual.
Without a carrier
omnibus/offline books nothing: its label's tracking number is the shipment's
tracking_number option (typed from the post office's receipt), else its reference, else a
random 12-character number; its trackingUrl is the tracking_url option with {number}
replaced; it has no document.
$label = $registry->get('boutique')->ship(new Shipment($shop, $customer, [new Parcel(500)], options: ['tracking_number' => '6A12345678901']));
// 6A12345678901, https://www.laposte.fr/outils/suivre-vos-envois?code=6A12345678901
Proposer une modification docs/usage-shipping.md
Tracking
$tracking = $gateway->track('1Z999AA10123456784', 'fr'); // Tracking
track() asks the carrier where a parcel is. The second argument is the locale of the events'
descriptions (fr by default); carriers that translate use it, the others ignore it, and each
carrier's manual says how it is sent (fr becomes fr_FR for UPS).
Reading a tracking
use Omnibus\Model\TrackingStatus;
$tracking = $gateway->track($order->getTrackingNumber());
echo $tracking->status->value, "\n"; // in_transit, delivered...
foreach ($tracking->events as $event) { // oldest first
printf("%s %-20s %s%s\n",
$event->at->format('Y-m-d H:i'),
$event->status->value,
$event->description,
$event->location ? " ({$event->location})" : '',
);
}
if ($tracking->status->isFinal()) {
// delivered or returned: stop polling this parcel
}
statusis the status of the latest event, folded ontoTrackingStatus's eight cases (Concepts); each carrier's manual has its mapping table.eventsare oldest first;latest()is the last one, ornullwhen the carrier gave none.TrackingEvent::$codekeeps the carrier's own event code, for whoever needs more than the eight statuses.
A carrier that reads its events' texts looks for the negative ones ("not delivered",
"undelivered") before "delivered", which they contain: a failed delivery is an EXCEPTION.
When the label carried a carrierReference (Amazon), pass it in place of the tracking number:
it tells the carrier which of its carriers to ask. The tracking's trackingNumber is still the
parcel's.
A tracking number the carrier does not know is, depending on the carrier, an empty tracking with
status UNKNOWN or a CarrierException carrying the carrier's error code: check each carrier's
manual.
Polling
Omnibus has no webhook and no scheduler: an application polls. A Symfony application can do it from a scheduled message (symfony/scheduler) or a command run by cron:
foreach ($orders->inTransit() as $order) {
$tracking = $registry->get($order->getCarrier())->track($order->getTrackingNumber());
$order->setShippingStatus($tracking->status->value);
if ($tracking->status->isFinal()) {
$order->markShippingDone();
}
}
Keep the gateway's name with the order (the registry's name, relais, ups...) so the parcel is
tracked by the carrier that booked it.
Without a carrier
omnibus/offline answers every number with status UNKNOWN and no event: there is no carrier to
ask. Show the label's trackingUrl instead.
Carriers whose tracking needs other credentials
Royal Mail's and DTDC's tracking APIs have credentials of their own: without
tracking_client_id and tracking_client_secret (Royal Mail) or tracking_username and
tracking_password (DTDC), supports(Tracking::class) is false and track() throws
RequestNotSupportedException before any call.
Proposer une modification docs/usage-tracking.md
Pickup points
$points = $gateway->pickupPoints($address, 10, $parcel); // PickupPoint[], nearest first
pickupPoints() answers the relays, lockers and post offices near an address that can take the
parcel: at most $limit (10 by default), nearest first. The parcel is optional; carriers that
filter by weight use it (Mondial Relay sends its weight), the others ignore it.
A pickup point selector
use Omnibus\Model\Address;
use Omnibus\Model\Parcel;
$near = new Address('', [], '75009', 'Paris', 'FR'); // a postcode and a city are enough for most carriers
$points = $gateway->pickupPoints($near, 5, new Parcel(800));
foreach ($points as $point) {
printf("%s %s, %s %s%s\n",
$point->id,
$point->name,
$point->address->postcode,
$point->address->city,
null !== $point->distance ? sprintf(' (%d m)', $point->distance) : '',
);
foreach ($point->openingHours as $weekday => $slots) { // 1 = Monday
echo ' ', $weekday, ': ', implode(', ', array_map(fn ($s) => "{$s[0]}-{$s[1]}", $slots)), "\n";
}
}
Give the chosen PickupPoint::$id back as Shipment::$pickupPoint when booking
(Shipments and labels). latitude and longitude, where
the carrier gives them, place the points on a map.
What the contract promises - nearest first, no more than $limit, distance in metres, opening
hours by ISO weekday as "HH:MM" pairs - is checked by the conformance suite on recorded answers.
Carriers differ in how far they search and in what they know: some give no distance, some no
opening hours. Each carrier's manual says what its search sends and what comes back.
Carriers that number the days otherwise (UPS and Canada Post count from Sunday) or write their
times otherwise (UPS's military 930) are converted; Canada Post's hours cost one call per office.
Aramex gives no distance and no order: its offices come by postcode, city, then distance to the
city's offices, with distance null.
Click and collect
omnibus/offline answers the shop's own counters, configured, wherever the customer is:
'retrait' => ['factory' => 'offline', 'options' => [
'pickup_points' => [
['id' => 'shop', 'name' => 'La boutique', 'street' => ['1 rue de Rivoli'], 'postcode' => '75001', 'city' => 'Paris', 'country' => 'FR',
'phone' => '+33102030405', 'latitude' => 48.8606, 'longitude' => 2.3376,
'opening_hours' => [2 => [['10:00', '19:00']], 3 => [['10:00', '13:00'], ['14:00', '19:00']]]],
],
]],
The points come in the configured order, cut to $limit, with no distance.
Carriers without pickup points
TNT, GLS, DB Schenker, Royal Mail, Canpar, Australia Post, Blue Dart, DTDC, SF Express, JD Logistics,
ZTO and Amazon answer no pickup point search; DHL answers one only with location_api_key (its
Location Finder's key). There pickupPoints() throws RequestNotSupportedException before any
call: check supports(Omnibus\Request\Pickup::class) to hide the selector.
Proposer une modification docs/usage-pickup.md
Slips, cancellations and customs documents
Printing a label again
use Omnibus\Model\Label;
use Omnibus\Request\GetSlip;
if ($gateway->supports(GetSlip::class)) {
$label = $gateway->slip($trackingNumber, Label::PDF);
}
slip() asks the carrier for the label of a shipment booked earlier. Mondial Relay, Australia
Post, Canpar and DTDC answer it (the last three for a label that did not come at booking); for
every other carrier, keep the document ship() returned (Label::$content, or download
Label::$url while it is valid).
Cancelling a shipment
use Omnibus\Request\Cancel;
if ($gateway->supports(Cancel::class) && $gateway->cancel($label->carrierReference ?? $label->trackingNumber)) {
// voided: the carrier will not invoice it
}
cancel() voids a shipment not handed over yet and answers true when the carrier voided it,
false when the carrier answered that it did not (handed over already, too late; how each carrier
says so is in its manual - Amazon answers true whenever the call succeeds). Chronopost,
GLS, Purolator, Canpar, Australia Post, Blue Dart, DTDC, SF Express, JD Logistics, ZTO and Amazon
answer it.
Amazon cancels by its shipment id and SF Express by its order id, not by the tracking number: both
put what they need in the label's carrierReference, which cancel() takes.
Customs documents
use Omnibus\Request\Paperless;
$request = new Paperless($trackingNumber, ['invoice' => file_get_contents('invoice.pdf')]);
if ($gateway->supports(Paperless::class)) {
$accepted = (bool) $gateway->execute($request)->getResult();
}
Paperless sends customs documents electronically (paperless trade) for a shipment leaving the
customs union: document type (invoice, proforma...) => PDF content. It is part of the contract,
with no typed shortcut on the gateway, and no carrier answers it yet: supports() is false
everywhere.
Any request, by hand
Every typed method is a shortcut for execute():
use Omnibus\Request\Rating;
$request = $gateway->execute(new Rating($shipment));
$rates = $request->getRates();
execute() hands the request back answered, or throws RequestNotSupportedException (no action
supports it) or CarrierException (the carrier failed, or the action left it unanswered).
Proposer une modification docs/usage-after-shipping.md
Configuration reference
The registry's configuration
new Registry($factories, $config), and the bundle's omnibus.gateways (Symfony),
take the same shape: gateway name => factory and options.
[
'<gateway name>' => [
'factory' => '<factory name>', // required: a factory's getName()
'options' => [/* ... */], // optional, default []: handed to the factory's create()
],
]
The gateway name is the application's (relais, express, retrait); the factory name is the
carrier package's (mondial_relay, ups, offline). Several gateways may use the same factory
with different options (two Chronopost accounts, say).
The rates option
Every carrier accepts rates: a list of services priced from configuration, by weight. Set, it
adds an Omnibus\Action\ConfiguredRatingAction, and every carrier that has a rating API leaves
its own out: rate() then calls nothing.
rates:
- service: relay # required: the code given back as Rate::$service
label: 'Point relais' # default: the service
currency: EUR # default: EUR
days: 4 # default: null (unknown)
to_pickup_point: true # default: false
countries: [FR, BE] # default: every country
bands: { 500: 490, 1000: 590, 5000: 890 } # up to grams => minor units
| Key | Type | Default | |
|---|---|---|---|
service |
string | - | required; the service's code, compared with Shipment::$service |
label |
string | the service | Rate::$label |
currency |
string | EUR |
ISO 4217 |
days |
int | null |
Rate::$days |
to_pickup_point |
bool | false |
Rate::$toPickupPoint |
countries |
list of strings | []: every country |
the recipient countries served, ISO 3166-1 alpha-2, compared case-insensitively |
bands |
map int => int | [] |
weight in grams (inclusive upper bound) => price in minor units; sorted by weight before use |
How a shipment is priced, for each service in the list:
- skipped when
countriesis set and the recipient's country is not in it; - skipped when
Shipment::$serviceis set and is not this service; - each parcel takes the price of the first band whose weight it does not exceed; a parcel heavier than every band (or a service without bands) skips the service;
- the parcels' prices are added.
The rates are sorted cheapest first. A service skipped gives no rate; no service left gives [].
Carriers' options
Each carrier's manual explains its options; the table lists them as each factory declares them (required options must be set and not empty; the others with their defaults).
| Factory | Required | Optional (default) |
|---|---|---|
offline |
- | tracking_url (null), pickup_points ([]) |
mondial_relay |
enseigne, private_key |
sandbox (fills the test brand), endpoint (https://api.mondialrelay.com/Web_Services.asmx), collection (CCC), download_labels (false) |
colissimo |
- | - |
chronopost |
account_number, password |
sub_account (null) |
ups |
client_id, client_secret, account_number |
sandbox (false) |
fedex |
client_id, client_secret, account_number |
sandbox (false) |
dhl |
api_key, api_secret, account_number |
sandbox (false), location_api_key (null: no pickup points) |
tnt |
username, password, account_number |
sender (null: the shipment's sender is the collection address) |
gls |
username, password, contact_id |
sandbox (false) |
db_schenker |
- | api_key, account_number (null: tracking only) |
usps |
client_id, client_secret |
sandbox (false), crid, mid, account (null: no labels) |
royal_mail |
api_key |
tracking_client_id, tracking_client_secret (null: no tracking) |
canada_post |
username, password, customer_number |
contract_id (null), sandbox (false) |
purolator |
key, password, account_number |
sandbox (false) |
canpar |
user_id, password, shipper_number |
sandbox (false) |
auspost |
api_key, password, account_number |
sandbox (false) |
aramex |
username, password, account_number, account_pin, account_entity, account_country |
sandbox (false) |
bluedart |
client_id, client_secret, login_id, licence_key, customer_code |
origin_area (null), sandbox (false) |
dtdc |
api_key, customer_code |
tracking_username, tracking_password (null: no tracking), sandbox (false) |
sf_express |
partner_id, checkword |
monthly_card (null), sandbox (false) |
jdl_express |
app_key, app_secret, access_token, customer_code |
sandbox (false) |
zto_express |
company_id, key |
partner_code (null), sandbox (false) |
amazon |
client_id, client_secret, refresh_token |
region (eu), business_id (null), carrier_id (AMZN_UK: the carrier a bare tracking number is tracked with), sandbox (false) |
Besides these, each carrier reads shipment options (Shipment::$options: label_format,
description, instructions, Blue Dart's prices...): its manual lists every option its code
reads.
Reserved keys
Options whose name starts with omnibus. are the factory's own (omnibus.factory_name,
omnibus.api, omnibus.action.*... Concepts). Given in the
options, they win over the factory's defaults: omnibus.action.tracking set to an action of the
application's replaces the carrier's tracking. That is a way to adapt one operation without
writing a carrier; an option set to null there removes the action.
Proposer une modification docs/configuration.md
Errors
Every exception Omnibus throws implements Omnibus\Exception\OmnibusException (a \Throwable):
| Exception | Extends | When |
|---|---|---|
RequestNotSupportedException |
\LogicException |
the gateway has no action for the request: supports() says so beforehand. Thrown before any call. Message: The "dhl" gateway does not support Pickup. |
InvalidConfigException |
\LogicException |
a gateway not configured, a factory not installed, a required option missing or empty, an action given an API client of the wrong class |
CarrierException |
\RuntimeException |
the carrier refused, or could not be reached; an action that left its request unanswered |
Shipment's constructor throws \InvalidArgumentException (not an OmnibusException) when given
no parcel.
CarrierException
use Omnibus\Exception\CarrierException;
try {
$label = $gateway->ship($shipment);
} catch (CarrierException $e) {
$e->carrier; // the gateway's name: "ups", "mondial_relay"
$e->carrierCode; // the carrier's own error code, when it gave one; else null
$e->getMessage(); // "[ups] <the carrier's message>"
$e->getPrevious(); // a copy of the HTTP client's exception, the JSON error... when there was one
}
The message is prefixed with the carrier's name in brackets. The family's rule, which every
carrier keeps and the conformance suite checks on each operation that calls the carrier
(Writing a carrier): every failure of the carrier is a
CarrierException naming it -
- an HTTP status of 400 or more: 401 and 403 (a credential refused), 404, 429 (too many requests), 5xx;
- a network error (refused, timed out, DNS);
- a body the carrier's client cannot read (an HTML error page from a proxy with a 200 included);
- a login or token refused.
Never an HTTP client's own exception, never a \TypeError from an unexpected shape, and never an
empty answer in place of the failure: no rates because the carrier is down is an exception, not
[].
No secret in a failure
What a carrier throws keeps the request's secrets out, for a log that writes the whole chain,
arguments included (zend.exception_ignore_args off). The HTTP client's message quotes the URL
called: in the CarrierException's message its key parameters, a password in its user part and every secret
of the request (a key, a password, a bearer or basic credential) are masked as ***
(Omnibus\Http\Redaction::text()). The previous exception is a copy of the client's
(Redaction::copy(): the same class and code, that message, its own previous copied the same
way, without the response or request a client's exception holds), and no trace of the chain keeps
its arguments, where the options, headers and body stood (Redaction::withoutArguments()). A
GatewayFactory::create() that fails (a missing option) throws without arguments in its trace
either: they held the options, credentials included.
What to catch where
| Where | Catch |
|---|---|
| a checkout showing rates | CarrierException: show configured or flat prices, or "unavailable"; log it |
| booking after payment | CarrierException: keep the order, retry later (a 429 or 5xx is often temporary), alert someone on a 401/403 |
| building the registry, a deploy | InvalidConfigException: a configuration error, to fix, not to retry |
| a feature a carrier may lack | check supports() first; RequestNotSupportedException is a programming error |
A carrier's message and code are the carrier's, untranslated: show the customer a message of the shop's, keep the carrier's for the logs.
Proposer une modification docs/errors.md
Testing
Testing an application that ships with Omnibus. Writing a carrier and its conformance suite: Writing a carrier.
The offline gateway as a fake
omnibus/offline calls nothing and answers rates, shipments, tracking and pickup points from its
options: in a test, configure the gateway the application uses with the offline factory.
# config/packages/test/omnibus.yaml
omnibus:
gateways:
relais:
factory: offline
options:
rates: [{ service: 24R, label: 'Point Relais', to_pickup_point: true, bands: { 30000: 490 } }]
tracking_url: 'https://example.org/track/{number}'
pickup_points: [{ id: FR-066974, name: 'Relais test', street: ['1 rue de test'], postcode: '75009', city: Paris, country: FR }]
The gateway keeps the name relais for the application; its getName() is offline. Booking
answers the shipment's tracking_number option, else its reference; tracking answers UNKNOWN.
A carrier on recorded answers
To test the application's handling of a real carrier's answers - and of its failures - give the
carrier's factory a MockHttpClient (symfony/http-client):
use Omnibus\Exception\CarrierException;
use Omnibus\Testing\Fixtures;
use Omnibus\Ups\UpsGatewayFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class CheckoutRatesTest extends TestCase
{
public function testUpsDownShowsNoRate(): void
{
$http = new MockHttpClient([
new MockResponse(json_encode(['access_token' => 'tok', 'expires_in' => 3600])),
new MockResponse('<html>503</html>', ['http_code' => 503]),
]);
$ups = (new UpsGatewayFactory($http))->create(['client_id' => 'id', 'client_secret' => 's', 'account_number' => 'A1B2C3']);
$this->expectException(CarrierException::class);
$ups->rate(Fixtures::shipment());
}
}
Omnibus\Testing\RecordedHttpClient (shipped with the core, needs symfony/http-client) answers by
route rather than in order: "METHOD fragment", "fragment" (matched when the URL contains it) or
"body:fragment" (matched on the request body, for SOAP), the first route declared that matches
answering; an array answer is sent as JSON, a string as it is, a closure is called with the method,
URL and options. requests() lists what was sent, unanswered() the requests no route matched.
RecordedHttpClient::answer($body, $status, $headers) and RecordedHttpClient::networkError()
build answers; failing($failure, $kept) gives the same routes failing, all but $kept.
use Omnibus\Testing\RecordedHttpClient;
$http = new RecordedHttpClient([
'POST /security/v1/oauth/token' => ['access_token' => 'tok', 'expires_in' => 3600],
'/Shop' => file_get_contents(__DIR__.'/fixtures/ups-shop.json'),
]);
Fixtures
Omnibus\Testing\Fixtures gives the addresses and shipments the carriers' own tests use:
| Method | |
|---|---|
Fixtures::shop() |
La Touche Originale, 12 rue de la Paix, 75002 Paris, FR, with company, e-mail and phone |
Fixtures::customer(string $country = 'FR') |
Émile Zola, 21 bis rue de Bruxelles / Bâtiment B, 75009 Paris, in $country, with e-mail and phone |
Fixtures::shipment(int ...$grams) |
shop to customer, one parcel of 800 g unless weights are given, reference CMD-1042 |
In a Symfony functional test
The registry and the gateways are services: replace a gateway's factory options per environment
(above), or replace the HTTP client the factories receive (http_client) with a
MockHttpClient in the test environment, as for any Symfony HTTP client test.
Proposer une modification docs/testing.md
The Docker harness
docker/ runs this package with every omnibus/* carrier installed - from GitHub (branch 2.x),
or from the checkouts beside this one when OMNIBUS_PLUGINS=../.. is set in docker/.env - and
a console that exercises them with the test keys in docker/.env.
cd docker && cp .env.dist .env # your carriers' test keys, when you have them
docker compose run --rm omnibus gateways
| Command | |
|---|---|
gateways |
the carriers installed and configured, what each does |
rate <gateway> |
the carrier's prices for a parcel |
ship <gateway> |
a shipment booked, its label saved under docker/labels/ |
track <gateway> <number> |
where a parcel is |
pickup <gateway> |
pickup points near the recipient |
slip <gateway> <number> |
a label fetched again |
cancel <gateway> <number> |
a shipment cancelled |
bare |
plain PHP: the registry built by hand, a parcel priced and handed over, UPS asked, what PHP loaded |
test |
every package's tests |
docker compose run --rm omnibus rate ups --to-country GB --weight 1200
docker compose run --rm omnibus ship chronopost --service 01
docker compose run --rm omnibus track ups 1Z999AA10123456784
docker compose run --rm omnibus pickup mondial_relay --to-postcode 75009
Options of the console's commands:
| Option | Commands | Default | |
|---|---|---|---|
--from-<field>, --to-<field> |
rate, ship, pickup |
HARNESS_SENDER_<FIELD>, HARNESS_RECIPIENT_<FIELD> from .env |
the sender's and the recipient's name, company, street, postcode, city, country, email, phone |
--weight, -w |
rate, ship, pickup |
800 |
grams |
--size |
rate, ship, pickup |
30x20x10 |
length x width x height, cm |
--value |
rate, ship, pickup |
2500 |
minor units |
--currency |
rate, ship, pickup |
EUR |
|
--reference, -r |
rate, ship, pickup |
OMNIBUS-<ymd-His> |
|
--service, -s |
ship |
- | the carrier's service code |
--pickup-point, -p |
ship |
- | a pickup point id |
--option, -o |
ship |
- | a shipment option, name=value, repeatable |
--locale, -l |
track |
fr |
|
--limit |
pickup |
10 |
A carrier's failure prints its message (and its code) and exits 1; a configuration error exits 2.
A carrier is "configured" once every variable its entry in docker/harness/config/gateways.php
needs is set in docker/.env.
Mondial Relay runs on its public test brand until MONDIAL_RELAY_ENSEIGNE is set; Chronopost has
no sandbox, its published test account is in .env.dist. Both are test identifiers the carriers
publish for everyone, not secrets.
Bare: no bundle, no container
The console above is a symfony/console application over a registry built by hand; bare is
less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing
else. It builds the Registry from the carrier packages installed, asks each gateway that can
be built what it does, prices a parcel of 500 g from Paris to Strasbourg and hands it over
through omnibus/offline - the shop's own rates, no network - then asks UPS what the same parcel
would cost: the real Rating API when UPS_CLIENT_ID, UPS_CLIENT_SECRET and UPS_ACCOUNT are
set, or with --recorded the answers kept in docker/harness/recorded/. Nothing is booked. Then
it lists what PHP loaded and exits 1 if a class of a framework is among it
(Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a
bridge, Doctrine, Twig):
$ docker compose run --rm omnibus bare --recorded
Omnibus in bare PHP: the registry built by hand, no bundle, no container.
offline rate ship track pickup
ups rate ship track pickup
omnibus/offline, 500 g from Paris to Strasbourg (the shop's own rates, no network):
0.00 EUR (Retrait en boutique), 6.90 EUR (Coursier)
handed over under 6A12345678901, followed at https://www.laposte.fr/outils/suivre-vos-envois?code=6A12345678901
collected at shop: La boutique
omnibus/ups, the same parcel, from the answers kept in recorded/:
12.90 EUR (Standard), 42.10 EUR (Worldwide Express, 1 day)
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a bridge, Doctrine, Twig): none
The answers in recorded/ were not taken from UPS: they are written in the shape its Rating API
documents, as in omnibus/ups's tests, on credentials that are manifestly not credentials.
bare --recorded --json prints the same whole - every class loaded, every file since the
autoloader - without a call: Tests/BareTest.php runs it in a process of its own and checks the
list.
The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness
volume of the omnibus-harness project.
Proposer une modification docs/harness.md
Writing a carrier
A carrier is a package omnibus/<carrier>, namespace Omnibus\<Carrier>\: a factory, an API
client, one action per request it answers, and its tests. omnibus/ups is a complete example.
ups/
UpsGatewayFactory.php # name, title, options, the API client, the actions
Api.php # the carrier's HTTP calls, its errors turned into CarrierException
Action/RatingAction.php # one class per request: Rating, Shipping, Tracking, Pickup, GetSlip, Cancel, Paperless
Tests/UpsGatewayTest.php # what is particular to the carrier
Tests/UpsContractTest.php # the conformance suite
The factory
final class AcmeGatewayFactory extends GatewayFactory
{
protected function populateConfig(Config $config): void
{
$config->defaults([
'omnibus.factory_name' => 'acme', // snake_case, the name gateways are configured with
'omnibus.factory_title' => 'Acme',
'omnibus.required_options' => ['api_key'], // missing: InvalidConfigException naming it
'tracking_key' => null,
'omnibus.api' => fn (Config $c) => new Api($this->http ?? HttpClient::create(), (string) $c['api_key']),
'omnibus.action.rating' => static fn (Config $c) => $c->get('rates') ? null : new RatingAction(),
'omnibus.action.shipping' => new ShippingAction(),
'omnibus.action.tracking' => static fn (Config $c) => $c['tracking_key'] ? new TrackingAction() : null,
]);
}
}
The rules every carrier keeps:
- Building a gateway calls nothing. A token is asked for on the first call, not in
create(). The factory takes the application's HTTP client and makes its own when given none. supports()is the truth. A request the carrier does not answer has no action, and the gateway raisesRequestNotSupportedExceptionbefore any call. An operation that needs an option of its own (a second API's credentials, a payer) has no action without that option, rather than an action that fails when called:supports(Tracking::class)is false untiltracking_keyis set.- A
ratesoption replaces the rating API. The carrier's rating action is not registered whenratesis set;Action\ConfiguredRatingActionprices from the configuration, with no call. - Every failure is a
CarrierExceptionnaming the carrier (its factory name): an HTTP status of 400 or more, a network error, a body that is not the carrier's format (an error page from a proxy included), a login refused. CatchSymfony\Contracts\HttpClient\Exception\ExceptionInterfaceand\JsonException, check the status before trusting the body, and put the carrier's own error code in$carrierCode. A rating action that asks per service may skip a service the carrier refuses, but when every call failed it throws the failure: no rate because the carrier failed is not "no service". - Models keep their invariants:
carrieris the factory name; amounts in minor units, ISO 4217 currencies, ISO 3166-1 alpha-2 countries; rates cheapest first; pickup points nearest first and no more than the limit asked, opening hours"HH:MM"by ISO weekday; tracking events oldest first;CancelandPaperlessanswertrueorfalse.
Conformance suite
Omnibus\Testing\GatewayContractTests is those rules as PHPUnit tests. A carrier's test case uses
the trait and gives its factory, working options and the carrier's recorded answers; the suite runs
offline, through Omnibus\Testing\RecordedHttpClient, and no test reaches the network.
use Omnibus\Testing\GatewayContractTests;
final class AcmeContractTest extends TestCase
{
use GatewayContractTests;
protected function gatewayFactory(HttpClientInterface $http): GatewayFactoryInterface
{
return new AcmeGatewayFactory($http);
}
protected function gatewayOptions(): array
{
return ['api_key' => 'test', 'tracking_key' => 'test', 'sandbox' => true];
}
protected function recordedAnswers(): array
{
return [
'POST /oauth/token' => ['access_token' => 'tok', 'expires_in' => 3600], // array: sent as JSON
'/v1/rates' => file_get_contents(__DIR__.'/recorded/rates.json'), // string: the body as it is
'/v1/labels/' => RecordedHttpClient::answer('%PDF-1.4', 200, ['content-type' => 'application/pdf']),
'body:<TrackRequest' => fn (string $method, string $url, array $options) => '<soap:Envelope>...',
];
}
}
A route is "METHOD fragment" or "fragment", matched when the URL contains the fragment, or
"body:fragment", matched on the request body (a SOAP operation); the first route declared that
matches answers. A request no route answers fails at the transport and the suite names it.
What it checks, for every carrier:
| Test | |
|---|---|
testTheFactoryNamesTheGatewayItBuilds |
the factory's name (snake_case) is the gateway's, a title, no call while building |
testTheRegistryBuildsTheGatewayFromItsConfiguration |
Registry builds it from {factory, options}, once |
testEachRequiredOptionIsRequired |
each required option missing is an InvalidConfigException naming it; each operation option missing makes its requests unsupported |
testRating, testShipping, testTracking, testPickupPoints, testSlip, testCancellation, testPaperless |
supported: answered from the recorded answers, models valid; not supported: RequestNotSupportedException, no call |
testConfiguredRatesReplaceTheRatingApi |
with rates, the configured price and only it, no call |
testTheCarriersFailuresAreCarrierExceptions |
for each supported operation that calls the carrier: HTTP 401, 403, 404, 429, 500, 503, a network error, an HTML page with a 200, a login refused - a CarrierException naming the carrier, never another exception or an answer; skipped for a gateway that calls no carrier |
Hooks, where a carrier legitimately differs:
| Hook | Default | |
|---|---|---|
contractShipment() |
Paris to Paris, 800 g | a shipment the carrier serves (domestic US for USPS...) |
contractTrackingNumber() |
1234567890 |
a number the recorded answers know |
contractPickupAddress() |
the shipment's recipient | |
sessionRoutes() |
none | routes that keep answering while the others fail (a token), so a failure is checked on the call itself; the login refused is checked apart |
operationOptions() |
none | option => requests supported only with it |
issuesDocuments() |
true | false for a gateway whose label carries no document (offline: the shop prints its own; ZTO: an electronic waybill) |
The recorded answers are written from the carrier's published documentation and, where a sandbox was reached, from its answers: the suite proves that the carrier's code keeps the contract on those answers, not that the carrier's API still answers that way (see each carrier's README for what was verified against the real service).
Requires, in the carrier's require-dev: phpunit/phpunit and symfony/http-client (a carrier
that calls an API requires the latter already). Omnibus\Testing\Fixtures gives the addresses and
shipments the carriers' own tests use, shipped with the core so a carrier's tests run where it is
installed alone.
Proposer une modification docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-25 in the harness, every package from its checkout, PHP 8.4,
PHPUnit 11.5: 383 tests, 2231 assertions, green; 2 skipped. Each of the 23 carriers runs the
conformance suite (Omnibus\Testing\GatewayContractTests, Writing a carrier)
in Tests/<Carrier>ContractTest.php, offline, on recorded answers:
- the factory and the registry, required and operation options;
- each request supported or refused as
supports()says, an unsupported one sending nothing; - the models' invariants (carrier names, amounts in minor units, ISO codes, rates cheapest first,
pickup points nearest first and within the limit, opening hours by ISO weekday as
HH:MM, tracking events oldest first); - configured rates replacing the rating API;
- every failure of the carrier (HTTP 401, 403, 404, 429, 500, 503, network, unreadable body, login
refused) a
CarrierExceptionnaming it.
The two skipped tests are the failure check of omnibus/offline and omnibus/colissimo, which
call no carrier: skipped, not passed.
The core's own tests cover the gateway, the registry, configured rates, the Symfony bundle
(Tests/Bridge/OmnibusBundleTest.php) and the plain-PHP run with no framework class loaded
(Tests/BareTest.php). The capability table of the index was probed with
supports() on 2026-10-25.
What is not verified
- That the carriers' APIs answer as recorded. The recorded answers are written from each carrier's
published documentation; no carrier was called for this run. Mondial Relay and Chronopost publish
test accounts the harness can call (
docker compose run --rm omnibus track mondial_relay ...). - The API shapes of DB Schenker's bookings, Blue Dart, DTDC, SF Express, JD Logistics and ZTO are read from documentation only, and flagged "unverified" in their READMEs and manuals.
- Colissimo's web services are not wired:
omnibus/colissimoanswers configured rates only. - The conformance suite checks the shapes the code returns, not the meaning of each carrier's fields. The defects found while writing these manuals (a status mapping that read "not delivered" as delivered, a pickup point id never sent, a cancellation with the wrong id, opening hours not read...) were fixed against the carriers' published documentation; what a live answer must still confirm is said in each carrier's manual.
- The Laravel page was written from the registry's API, not run in a Laravel application.
Responsibility
omnisense/omnibus and its omnibus/* carriers are independent, unofficial clients: none is
affiliated with, endorsed by or sponsored by the services it reaches. Each service keeps its own
terms (carrier accounts and contracts, keys, rate limits, how labels and tracking data may be
used), named with a link in each carrier's README. The MIT licence covers this code only and grants
no right to any service or to its data; whoever runs these packages complies with those terms.