Aller au contenu

omnisense/omnibus

Omnibus: one contract for parcel carriers - rates, labels, tracking, pickup points - and its Symfony bundle (the Payum of shipping).

Étage
Famille
Version
2.x
Suite de conformité
branchée, CI non lue
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omnisense/omnibus:2.x-dev

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 rates option, Configuration).
  • Click and collect, or parcels the shop hands over itself: omnibus/offline answers 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

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/omnibus requires symfony/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/offline and omnibus/colissimo call nothing and require neither.
  • No ext-soap: the SOAP carriers (Chronopost, Purolator, Canpar) write and read their XML themselves. omnibus/mondial-relay requires ext-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, a MockHttpClient in a test - and with none given makes its own (HttpClient::create());
  • the registry, built from the factories and the gateways' options, by name: get(), and create($name, $overrides) for keys typed in a back office;
  • the gateways it gives: rate(), ship(), track(), pickupPoints(), and for the carriers that do them slip() and cancel() - 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\OmnibusBundle registers the factories on the application's http_client, builds the registry from config/packages/omnibus.yaml and 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 Registry once, 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():

  1. createConfig($options) - a Config (an ArrayObject) holding the options given, then populateConfig() fills each key the options did not set with the carrier's default;
  2. the keys of omnibus.required_options are checked: one missing, '' or [] is an InvalidConfigException listing every missing key;
  3. omnibus.api - the carrier's API client, often a closure of the Config - is built;
  4. every key starting with omnibus.action. that is (or whose closure returns) an ActionInterface becomes an action; a closure returning null registers no action, which is how a carrier makes an operation depend on an option. Actions implementing ApiAwareInterface receive the API client;
  5. when the rates option is set, an Action\ConfiguredRatingAction is added last;
  6. a Gateway is returned, named by omnibus.factory_name and titled by omnibus.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::$carrier is 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
}
  • status is the status of the latest event, folded onto TrackingStatus's eight cases (Concepts); each carrier's manual has its mapping table.
  • events are oldest first; latest() is the last one, or null when the carrier gave none.
  • TrackingEvent::$code keeps 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:

  1. skipped when countries is set and the recipient's country is not in it;
  2. skipped when Shipment::$service is set and is not this service;
  3. 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;
  4. 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 raises RequestNotSupportedException before 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 until tracking_key is set.
  • A rates option replaces the rating API. The carrier's rating action is not registered when rates is set; Action\ConfiguredRatingAction prices from the configuration, with no call.
  • Every failure is a CarrierException naming 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. Catch Symfony\Contracts\HttpClient\Exception\ExceptionInterface and \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: carrier is 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; Cancel and Paperless answer true or false.

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 CarrierException naming 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/colissimo answers 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.

Proposer une modification docs/verified.md