Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Mondial Relay Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

  • Install the release with 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.
  • Collect four credentials and put them in 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'),
);
  • Look first at the test suite: it contains captured sandbox responses (shipment creation, PDF label, relay point search), which are the fastest reference for the exact payload/response shapes you will be mapping to your models. In 4.2 look specifically for the coordinate-search fixtures and the Information/Localisation1/Localisation2/Horaires fields — those back locker, directions and schedule().
  • First use case to wire up: relay point search at checkout (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.

Implementation Patterns

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:

  1. Checkout: search relay points by postcode or coordinates, present name + address + distance (in kilometres) + opening hours from schedule() + a locker badge where $locker is set; persist the chosen PointRelais id on the order.
  2. Fulfilment: build the shipment payload, call create, read the shipment number from the <Shipment ShipmentNumber="…"> attribute, persist it before downloading the label.
  3. Download the PDF label and store it on your media disk.

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.

Gotchas and Tips

  • Search input is validated pre-flight in 4.2. A request with neither post code nor coordinates, with only one of latitude/longitude, or with out-of-range coordinates throws \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".
  • ISO day numbering. 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.
  • Named-argument break. 4.1 renamed credential parameters to MR Connect naming: 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.
  • Distance units changed in 4.1. The metres-as-kilometres bug is fixed, so anything previously sorted or filtered on distance (radius limits, "nearest 3") is on a different scale. Any distances already persisted are wrong by ×1000 — recompute or discard them. Coordinate search makes radius filtering much more natural; if you store lat/lng on your ParcelShop rows you can sort locally and skip the API for repeat views.
  • Whitespace in relay point data. Padding is stripped and runs of spaces collapse, but MR's apostrophe→space substitution is a data quirk, not a bug you can fully normalise ("RUE D'ARMOR" arrives as "RUE D ARMOR"). If you compare addresses or match against your own data, normalise both sides. 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.
  • Warnings are no longer silent. Non-blocking API warnings are emitted at 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.
  • Never dump request/response bodies on failure. The library deliberately stopped copying HTTP response bodies into exception messages because MR echoes credentials in them, and it never logs credentials or bodies itself. Don't undo that work in your own 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.
  • Configuration failures are pre-flight. Missing credentials throw 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.
  • Sandbox needs real credentials. The README previously claimed 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".
  • Upgrading from a 4.0.x build: go to 4.2 rather than stopping at 4.1 — label creation and relay point search were entirely broken in 4.0.x (missing XML namespace, wrong signing order, success/warning/error misclassification). Delete any workaround code — retry loops, "Incomplete API response" string matching, manual XML massaging — as part of the upgrade, it will now actively interfere.
  • Extension points are limited to 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log