ernadoo/mondial-relay
Framework-agnostic PHP client for Mondial Relay: create shipment labels via the V2 REST API and search relay points via the V1 SOAP API. Supports sandbox testing, tracking URLs, and label PDFs.
composer require and pin ^4.2 — 4.1 was the first version where label creation and relay point search actually work end to end against the sandbox, and 4.2 adds coordinate search plus a real opening-hours API on top of that working baseline.config/services.php / .env, using the MR Connect names the constructor now expects:// config/services.php
'mondial_relay' => [
'api_login' => env('MR_API_LOGIN'),
'api_password' => env('MR_API_PASSWORD'),
'brand_code' => env('MR_BRAND_CODE'), // 8 chars, padded internally for search
'private_key' => env('MR_PRIVATE_KEY'),
],
MondialRelayClient::create(
apiLogin: config('services.mondial_relay.api_login'),
apiPassword: config('services.mondial_relay.api_password'),
brandCode: config('services.mondial_relay.brand_code'),
privateKey: config('services.mondial_relay.private_key'),
);
Information/Localisation1/Localisation2/Horaires fields — those back locker, directions and schedule().SoapParcelShopClient, needs privateKey + brandCode), either by postcode or — new in 4.2 — by coordinates via ParcelShopSearchRequest::around($countryCode, $lat, $lng). Then shipment creation + label download at fulfilment (RestShipmentClient / MondialRelayClient, needs apiLogin + apiPassword). Both flows are cheap to exercise against the sandbox before touching production.Register the clients as singletons, one per brand account. If you ship for several brands, key the binding by brand code rather than resolving credentials inline at call sites — the credential set now maps 1:1 to MR Connect (apiLogin, apiPassword, brandCode, privateKey).
$this->app->singleton(MondialRelayClient::class, fn () => MondialRelayClient::create(
apiLogin: config('services.mondial_relay.api_login'),
// ...
));
Offer both search modes behind one finder. Postcode search is still the right default for a typed-address checkout; coordinate search is for "use my location" / map panning / store-locator views. Keep both behind your RelayPointFinder adapter so the controller doesn't care which was used:
$request = $coords
? ParcelShopSearchRequest::around('FR', $lat, $lng) // post code may be empty here
: ParcelShopSearchRequest::forPostCode('FR', '75011');
$points = $relayPointFinder->search($request);
Round or geohash the coordinates before you cache results keyed on them — a map that fires a search on every pan event will hammer the API with near-identical queries.
Render opening hours off schedule(), never off the raw string. openingHours is still the raw "0930-1300 1400-1900" payload; schedule() gives you an OpeningHours object you can iterate:
$schedule = $parcelShop->schedule();
foreach (range(1, 7) as $isoDay) { // ISO-8601: 1 = Monday
$hours = $schedule->on($isoDay);
// $hours->isEmpty() => closed that day
// $schedule->isAlwaysOpen() => 24/7 (useful for lockers)
// $hours->isOpenOn($isoDay) => boolean convenience on the collection
}
Keep the raw openingHours if you need to display MR's exact wording or diff against a stored snapshot; use schedule() for anything computed (open-now badges, "closes in 30 min", sorting by next opening).
Treat locker as a first-class variant, not a footnote. ParcelShop::$locker is parsed from MR's Information field. Lockers have different UX (no counter staff, size limits, often 24/7), so surface it in the picker — a customer who selects a locker expecting a shop assistant is a support ticket. Same for directions (Localisation1/Localisation2): render it as supplementary text ("opposite the pharmacy"), and don't assume either field is present.
Attach a dedicated PSR-3 logger once, in the provider, not per call.
$client->setLogger(Log::channel('mondial_relay'));
Use a channel with its own file/level. Shipments created and relay point searches land at info, API rejections at error with their codes, and previously silent API warnings at warning — that warning stream is your early indicator that MR is degrading a request without failing it.
Catch by exception type, not by message. The hierarchy now lets you branch on cause without parsing strings:
try {
$shipment = $client->createShipment($payload);
} catch (ConfigurationException $e) {
// Your config is wrong: missing apiLogin/apiPassword, or privateKey for search.
report($e); // alert, do not retry
throw $e;
} catch (TransportException $e) {
// MR unreachable / HTTP / SOAP / malformed response — safe to retry with backoff.
$this->release(60);
} catch (ApiException $e) {
// MR understood us and said no. Surface the codes.
Log::warning('MR rejected shipment', ['errors' => $e->getErrors()]);
$order->markShippingFailed($e->getErrors());
}
All three extend MondialRelayException, so a single catch (MondialRelayException $e) around a multi-step flow is still valid when you only need "shipping failed" behaviour. Note that 4.2's bad-input cases throw plain \InvalidArgumentException — validate before the call so it never reaches your shipping catch blocks.
Translate messages off the exception class, not the text. Keep a lang/*/shipping.php map keyed by exception type (and, for ApiException, by MR code) so a French customer sees a French message and support sees the code. This is the stated reason the two new exception classes were added.
Typical order flow:
schedule() + a locker badge where $locker is set; persist the chosen PointRelais id on the order.<Shipment ShipmentNumber="…"> attribute, persist it before downloading the label.Queue the shipment creation. Put it in a job with $tries = 3 and backoff(); classify ConfigurationException and ApiException as non-retryable ($this->fail($e)) and TransportException as retryable. Persisting the shipment number first keeps the job idempotent enough to avoid duplicate labels on a redelivery. A malformed search request (\InvalidArgumentException) is a programming error — let it bubble in tests rather than catching it.
Wrap the concrete clients behind your own small interface (ShipmentGateway, RelayPointFinder) in your app layer. The clients are concrete classes with a fixed constructor, so an adapter is the cheapest seam for faking them in your own tests without hitting the sandbox — this matters more in 4.2 now that search has two input shapes and richer output.
\InvalidArgumentException before any network call. Validate the form request first (both-or-neither, and lat/lng ranges) so users get a field error instead of a 500, and so your retry logic never sees it.schedule() can be empty or uniform. OpeningHours::isEmpty() distinguishes "no hours published" from "closed today", and isAlwaysOpen() covers the 24/7 case that lockers frequently hit. Don't infer "open now" from an empty collection — check isEmpty() first, then fall back to "hours unavailable" in the UI rather than "closed".schedule()->on($isoDay) takes ISO-8601 days (1 = Monday, 7 = Sunday). If you feed it Carbon::now()->dayOfWeek (0 = Sunday) your "open now" badge is wrong on exactly the day people check it.openingHours is unchanged and still raw. 4.2 added schedule() alongside it rather than replacing it, so any existing parsing of the raw string keeps working — but it also keeps being wrong on edge cases (split shifts, overnight). Migrate the parsing to schedule() and keep the raw field for display only.login → apiLogin, password → apiPassword, customerId → brandCode, secretKey → privateKey. Positional calls are untouched. Grep for login:, password:, customerId:, secretKey: near client construction — it is a silent-ish break that will only fail at runtime.ApiException::getErrors() keys are ints. PHP casts numeric MR codes to integer array keys, so "10051" becomes 10051. $errors['10051'] will miss; isset($errors[10051]) works but array_key_exists('10051', …) does not. Normalise once (array_map('strval', array_keys($errors))) if you key translations by code.ParcelShop rows you can sort locally and skip the API for repeat views.directions is free text from two separate fields — concatenate defensively, either may be missing.locker is a flag off a free-text field. It comes from MR's Information column, so treat it as a hint for the UI, not a guarantee for routing logic. Never infer locker-vs-shop from the point's name or type code.warning. If your log pipeline alerts on warnings, expect new noise; if it doesn't, add a check on the mondial_relay channel — these often precede hard failures. Severity is driven by the Level attribute, not by the status code's leading 1.catch blocks or HTTP-client middleware — coordinate search puts more user-supplied values into the request, so a careless Log::debug($request) is now more revealing.ConfigurationException before any network call. Validate credentials at boot/deploy (a small artisan command or a health check) rather than discovering it when the first order ships.BDTEST accepts any credentials — it needs a valid API user. Budget for a sandbox account before you write integration tests, and note the sandbox's relay point data is sparser than production: expect missing opening hours and lockers in fixtures.brandCode padding is handled for you (padded to 8 characters, signed parameters in MR's exact order). Don't pre-pad or reorder yourself; it will break the signature with STAT 97 "Incorrect security key".setLogger() and the exception hierarchy. There is no HTTP-client injection hook, so timeouts, retries, circuit breaking and caching of search results belong in your adapter/job layer around the client, not inside it.How can I help you explore Laravel packages today?