Intégrer la livraison en PHP / Laravel
Appelez Yalidine, ZRExpress, Maystro, Ecotrack (DHD…), NoEST, E-com Delivery, MDM et Elogistia depuis votre application PHP avec une seule API : mêmes méthodes, mêmes modèles, mêmes statuts.
Le contrat complet (modèles, statuts, erreurs) est décrit sur la page Statuts, modèles et erreurs ; le mapping propre à chaque transporteur dans PROVIDERS.md.
1. Installer le SDK PHP
Prérequis : PHP ≥ 8.2 et Guzzle 7 (installé par Composer). Le paquet est publié sur Packagist :
composer require dz-delivery/sdk
Vous travaillez sur le SDK lui-même ? Pointez votre application vers une copie locale avec un dépôt de type path (les modifications du SDK sont visibles immédiatement) :
{
"repositories": [
{ "type": "path", "url": "../dz-delivery/packages/php" }
],
"require": {
"dz-delivery/sdk": "*@dev"
}
}Tout se trouve sous l’espace de noms DzDelivery\ (PSR-4).
2. Configurer un transporteur
Créez un client par transporteur avec ClientFactory::create($provider, $options). Gardez les identifiants dans des variables d’environnement ou un fichier .env (voir .env.example) : ne les écrivez jamais en dur et ne les commitez pas.
use DzDelivery\ClientFactory; $zr = ClientFactory::create('zrexpress', [ 'apiKey' => getenv('ZREXPRESS_API_KEY'), // envoyé en X-Api-Key 'tenant' => getenv('ZREXPRESS_TENANT'), // envoyé en X-Tenant ]); $yalidine = ClientFactory::create('yalidine', [ 'apiId' => getenv('YALIDINE_API_ID'), // envoyé en X-API-ID 'apiToken' => getenv('YALIDINE_API_TOKEN'), // envoyé en X-API-TOKEN 'fromWilayaId' => 16, // ou 'fromWilayaName' => 'Alger' ]); $maystro = ClientFactory::create('maystro', [ 'apiToken' => getenv('MAYSTRO_API_TOKEN'), // envoyé en Authorization: Token <apiToken> ]); $dhd = ClientFactory::create('ecotrack', [ 'token' => getenv('ECOTRACK_TOKEN'), // envoyé en Authorization: Bearer <token> 'courier' => 'dhd', // ou 'baseUrl' => 'https://<tenant>.ecotrack.dz' // 'autoValidate' => true, // valide chaque commande auprès du livreur dès sa création ]); $noest = ClientFactory::create('noest', [ 'apiToken' => getenv('NOEST_API_TOKEN'), // envoyé en Authorization: Bearer <apiToken> 'userGuid' => getenv('NOEST_USER_GUID'), // GUID partenaire, envoyé en user_guid // 'autoValidate' => true, // valide chaque commande auprès de NOEST dès sa création ]); $ecom = ClientFactory::create('ecom', [ 'apiKey' => getenv('ECOM_API_KEY'), // KEY, envoyé en X-API-Key 'apiToken' => getenv('ECOM_API_TOKEN'), // Token, envoyé en X-API-Token // 'autoValidate' => true, // crée les colis déjà confirmés ]); $mdm = ClientFactory::create('mdm', [ 'apiKey' => getenv('MDM_API_KEY'), // envoyé en x-api-key 'storeId' => getenv('MDM_STORE_ID'), // STR-…, requis pour créer des colis ]); $elogistia = ClientFactory::create('elogistia', [ 'apiKey' => getenv('ELOGISTIA_API_KEY'), // envoyé dans la query string (choix d'Elogistia) ]);
| Transporteur | Clés requises | Notes |
|---|---|---|
zrexpress | apiKey, tenant | — |
yalidine | apiId, apiToken | Plus fromWilayaId (code 1–58) ou fromWilayaName : votre wilaya d’origine, nécessaire à createParcel et getFees. GuepEx (même API) : 'baseUrl' => YalidineProvider::GUEPEX_DEFAULT_BASE_URL. |
maystro | apiToken | Token de la boutique. Une valeur commençant déjà par Token est envoyée telle quelle. |
ecotrack | token + exactement un de courier / baseUrl | Un seul provider pour tous les livreurs de la plateforme Ecotrack. courier : dhd, anderson, worldexpress, 48hrexpress, ecorapid, conexlog, msmgo, packers, rocketdelivery (plus l’alias andersondelivery). Tout autre tenant : baseUrl (https uniquement). autoValidate (par défaut false) : sans lui, les commandes restent pending jusqu’à leur validation dans le tableau de bord du livreur. |
noest | apiToken, userGuid | Tous deux dans le tableau de bord partenaire NOEST. autoValidate (par défaut false) : sans lui, les commandes restent pending (encore modifiables) jusqu’à validation. Les noms de communes suivent l’orthographe NOEST (Bab Azzouar) : utilisez les ids de getCommunes(). |
ecom | apiKey, apiToken | KEY et Token de la page développeur E-com. autoValidate (par défaut false) : sans lui, les colis restent pending (« En Préparation », encore modifiables) jusqu’à confirmation. |
mdm | apiKey (+ storeId pour créer) | MDM expédie des produits stockés chez MDM : chaque ligne de productList doit porter son id produit MDM (productId ou sku, PRD-…). Pas de cancelParcel. |
elogistia | apiKey | email optionnel (envoyé en mail, exigé par Elogistia ; par défaut [email protected]). Pas de liste de stop desks, ni de listParcels ni de cancelParcel. |
Des identifiants manquants lèvent une ShippingError de code AUTH_FAILED dès la création du client.
Options communes (tous les transporteurs)
| Option | Type | Défaut | Description |
|---|---|---|---|
baseUrl | string | URL de production du transporteur | Remplace l’URL de l’API (proxy, préproduction). |
timeoutMs | int | 15000 | Délai de connexion + requête. |
includeRaw | bool | true | Attache la réponse native du transporteur en ->raw sur les modèles renvoyés. false pour garder des objets légers. |
handler | callable | — | Handler / HandlerStack Guzzle, surtout pour les tests (voir §8). |
ClientFactory::providers() renvoie les clés supportées (['zrexpress', 'yalidine', 'maystro', 'ecotrack', 'noest', 'ecom', 'mdm', 'elogistia']). Une clé inconnue lève NOT_SUPPORTED.
Chaque client implémente DzDelivery\Contracts\ShippingProviderInterface : typez vos dépendances sur l’interface et changez de transporteur sans toucher au reste du code.
3. Méthodes
Toutes les méthodes acceptent soit un tableau, soit l’objet modèle correspondant (ParcelInput, FeeQuery, StopDeskQuery). Tous les modèles renvoyés exposent des propriétés publiques readonly en camelCase. Toute erreur lève DzDelivery\Errors\ShippingError (voir §5).
Méthodes supportées par transporteur
| Méthode | ZRExpress | Yalidine | Maystro | Ecotrack | NoEST | E-com | MDM | Elogistia |
|---|---|---|---|---|---|---|---|---|
createParcel | oui | oui | oui | oui (pending jusqu’à validation, voir autoValidate) | oui (idem, autoValidate) | oui (idem, autoValidate) | oui (produits du catalogue MDM, storeId) | oui |
track | oui | oui | oui (ou webhooks) | oui | oui | oui | oui | oui |
getFees | oui | oui | oui | oui (par wilaya) | oui (par wilaya) | oui (par wilaya) | oui (par commune) | oui (par wilaya) |
getWilayas / getCommunes | oui | oui | oui | oui | oui | oui | oui | oui |
getStopDesks | oui (hubs / points relais) | oui (centres) | oui (communeId requis) | oui (communes avec stop desk) | oui (stations) | oui (bureaux) | oui (bureaux partenaires) | NOT_SUPPORTED (Elogistia choisit le bureau) |
listParcels | oui (parcels/search) | oui (GET parcels/, status / updatedSince côté SDK) | oui (20 par page, filtres côté SDK) | NOT_SUPPORTED (bientôt) | NOT_SUPPORTED | oui (GET /colis) | oui (parcels/search) | NOT_SUPPORTED |
cancelParcel | oui | oui (seulement les colis encore « En préparation ») | oui (PATCH statut 50) | oui (seulement les commandes pas encore validées) | oui (seulement les commandes pas encore validées) | oui (seulement les colis « En Préparation ») | NOT_SUPPORTED | NOT_SUPPORTED |
healthCheck | oui | oui | oui | oui | oui | oui | oui | oui |
3.1 createParcel(ParcelInput|array $input): Parcel
Livraison à domicile :
$parcel = $client->createParcel([ 'recipientName' => 'Client Test', 'phone' => '0550123456', 'address' => '12 rue des Tests', 'wilayaId' => 16, // ou 'wilayaName' => 'Alger' 'communeName' => 'Bab Ezzouar', // ou 'communeId' => ... 'productList' => [ ['name' => 'Mug', 'quantity' => 2], ['name' => 'T-shirt', 'quantity' => 1], ], // ou 'productName' => 'T-shirt' 'price' => 3200, // montant payé par le destinataire, en DZD 'weight' => 1.5, 'orderId' => 'ORD-1001', ]); echo $parcel->trackingNumber; // à enregistrer avec votre commande echo $parcel->status; // en général 'pending' juste après la création
Livraison en stop desk (bureau) : récupérez l’id du bureau avec getStopDesks et passez-le en stopDeskId.
$desks = $yalidine->getStopDesks(['wilayaId' => 16, 'communeId' => 1630]); $parcel = $yalidine->createParcel([ 'recipientName' => 'Client Test', 'phone' => '0550123456', 'address' => 'Retrait au bureau', 'wilayaId' => 16, 'communeId' => 1630, 'productName' => 'T-shirt', 'price' => 2500, 'isStopDesk' => true, 'stopDeskId' => $desks[0]->id, ]);
Entrée (ParcelInput) :
| Champ | Type | Requis | Notes |
|---|---|---|---|
recipientName | string | oui | Nom complet. Yalidine le coupe au premier espace en prénom / nom. |
phone | string | oui | Tout format algérien courant (0550…, +213 550…). Normalisé par transporteur (ZRExpress reçoit +213…, les autres 0…). Numéro invalide → VALIDATION_ERROR avant tout appel HTTP. |
phone2 | string | non | Second numéro. |
address | string | oui | Texte libre. |
wilayaId | int|string | l’un des deux | Code officiel 1–58 ou Location->id du transporteur. |
wilayaName | string | l’un des deux | Résolu sans tenir compte de la casse ni des accents. |
communeId | int|string | l’un des deux | Location->id du transporteur, issu de getCommunes. |
communeName | string | l’un des deux | Résolu dans la wilaya. |
productName | string | l’un des deux | Description d’un seul produit. |
productList | array | l’un des deux | [['name' => string, 'quantity' => int ≥ 1, 'unitPrice' => ?number, 'sku' => ?string], …]. |
price | number | oui | Montant encaissé auprès du destinataire (COD), en DZD. 0 si prépayé. C’est le total payé par le destinataire : le transporteur en déduit ses frais. |
weight | number | non | kg. |
length, width, height | number | non | cm. Yalidine reçoit toujours ces quatre champs en entiers : 0 s’ils manquent, décimales arrondies au supérieur. ZRExpress place les dimensions sur le premier produit. Maystro ignore poids et dimensions. |
isStopDesk | bool | non | false par défaut (livraison à domicile). |
stopDeskId | string | si isStopDesk | Id du bureau renvoyé par getStopDesks (ZRExpress : l’UUID du hub). |
stockType | ?string | non | ZRExpress uniquement : "none" (défaut) | "local" | "warehouse". Surcharge par article via productList[].stockType ; local / warehouse exigent productId + sku. |
remark | string | non | Note pour le livreur (envoyée à Maystro uniquement, pas à ZRExpress ni à Yalidine). |
orderId | string | non | Votre référence de commande. Yalidine en exige une et génère dzd-… si elle manque. |
Nom ou id de wilaya / commune inconnu → NOT_FOUND avant l’envoi du colis.
Sortie (Parcel) :
| Propriété | Type | Description |
|---|---|---|
id | string | Id chez le transporteur (UUID chez ZRExpress / Maystro ; Yalidine : le numéro de suivi). À utiliser pour cancelParcel. |
trackingNumber | string | Numéro de suivi à montrer au client et à passer à track. |
status | string | Statut unifié (voir §4). |
provider | string | Clé du transporteur (zrexpress, yalidine, maystro…). |
labelUrl | ?string | URL du bordereau quand le transporteur la renvoie avec le colis (Yalidine). |
raw | mixed | Réponse native (avec includeRaw). |
Maystro refuse une commande avec le même nom de client + téléphone qu’une autre commande créée dans les dernières 24 heures. Relancer après un timeout peut donc échouer en VALIDATION_ERROR alors que la première commande a bien été créée.
3.2 track(string $trackingNumber): TrackResult
$result = $client->track('yal-ABC123'); echo $result->parcel->status; // statut unifié actuel foreach ($result->events as $event) { // le plus ancien en premier echo "{$event->at} {$event->status} {$event->label}\n"; }
| Propriété | Type | Description |
|---|---|---|
parcel | Parcel | État actuel (voir ci-dessus). |
events | TrackingEvent[] | Historique, du plus ancien au plus récent. Peut être vide. |
TrackingEvent :
| Propriété | Type | Description |
|---|---|---|
status | string | Statut unifié. |
label | string | Libellé natif, par exemple Tentative échouée (Client ne répond pas). |
at | string | Date ISO 8601 avec décalage horaire, par exemple 2025-12-30T13:24:34+01:00. |
raw | mixed | Événement natif. |
Un numéro de suivi inconnu lève NOT_FOUND. Maystro accepte l’id de commande ($parcel->id) ou le numéro de suivi.
3.3 getFees(FeeQuery|array $query): FeeQuote
$quote = $yalidine->getFees([ 'toWilayaId' => 31, 'toCommuneId' => 3101, // tarifs par commune chez Yalidine et Maystro 'weight' => 2, 'length' => 50, // cm : Yalidine facture le poids volumétrique s'il est plus élevé 'width' => 40, 'height' => 30, ]); echo $quote->deliveryFee; // 950 si oversize_fee = 50 DA/kg (voir ci-dessous) echo $quote->stopDeskFee; echo $quote->returnFee;
Entrée (FeeQuery) :
| Champ | Type | Requis | Notes |
|---|---|---|---|
toWilayaId | int|string | oui | Code 1–58 ou id du transporteur. |
toCommuneId | int|string | non | Yalidine / Maystro : sans lui, la première commune de la wilaya est utilisée. |
fromWilayaId | int|string | non | Yalidine : remplace le fromWilayaId du client. |
weight | number | non | kg. |
length, width, height | number | non | cm. Utilisés par Yalidine uniquement. |
isStopDesk | bool | non | true : deliveryFee est le tarif stop desk. |
Sortie (FeeQuote) :
| Propriété | Type | Description |
|---|---|---|
deliveryFee | int|float | Tarif pour le mode demandé (domicile ou stop desk). |
stopDeskFee | int|float|null | Tarif stop desk, quand le transporteur le fournit. |
returnFee | int|float|null | Frais de retour (Yalidine uniquement pour l’instant). |
currency | string | Toujours DZD. |
raw | mixed | Réponse native. |
Surpoids Yalidine. Poids facturé = max(weight, length × width × height × 0.0002) ; le poids volumétrique n’est utilisé que si les trois dimensions sont fournies. Au-delà de 5 kg, (facturé − 5) × oversize_fee (arrondi au dinar supérieur) s’ajoute aux deux tarifs. Dans l’exemple : 50 × 40 × 30 × 0,0002 = 12 kg → (12 − 5) × 50 = 350 DA, en plus d’un tarif domicile de 600 DA.
ZRExpress et Maystro ignorent poids et dimensions. Aucun tarif pour le mode demandé → NOT_FOUND.
3.4 getWilayas(): Location[]
foreach ($client->getWilayas() as $wilaya) { echo "{$wilaya->code} {$wilaya->name} ({$wilaya->id})\n"; }
3.5 getCommunes(int|string $wilayaId): Location[]
$communes = $client->getCommunes(16); // code, id transporteur ou (Yalidine/Maystro) nom
Sortie (Location, pour les deux méthodes) :
| Propriété | Type | Description |
|---|---|---|
id | string | Id du transporteur : à passer en wilayaId / communeId. ZRExpress utilise des UUID ; Yalidine et Maystro des nombres (id de wilaya = code officiel). |
name | string | Nom (français / latin). |
parentId | ?string | Communes : l’id de la wilaya. |
code | ?string | Wilayas : code officiel 1–58. Communes : code postal quand il est connu (Maystro). |
raw | mixed | Élément natif. |
Les wilayas et communes non livrables sont ignorées. Les résultats sont mis en cache pendant toute la vie de l’objet client : réutilisez un même client plutôt que d’en créer un par requête.
3.6 getStopDesks(StopDeskQuery|array $query): array
Liste les stop desks / points relais où le colis peut être retiré. Utilisez l’id renvoyé comme stopDeskId dans createParcel.
// ZRExpress : commencez par la wilaya seule, le filtre commune renvoie souvent zéro hub. $desks = $client->getStopDesks(['wilayaId' => 16]); foreach ($desks as $desk) { echo "{$desk->id} {$desk->name} {$desk->address}\n"; }
Entrée (StopDeskQuery) :
| Champ | Type | Requis | Notes |
|---|---|---|---|
wilayaId | int|string | oui | Code 1–58, id du transporteur ou nom. |
communeId | int|string | Maystro : oui ; autres : non | Id du transporteur ou nom. Filtre optionnel pour Yalidine / ZRExpress / Ecotrack. |
Sortie (StopDesk[]) :
| Propriété | Type | Description |
|---|---|---|
id | string | À passer en stopDeskId. |
name | string | Nom du bureau. |
address | ?string | Quand le transporteur la fournit. |
communeId | ?string | Commune du bureau. |
wilayaId | ?string | Wilaya du bureau. |
phone | ?string | Téléphone principal si disponible. |
openingHours | ?string | Horaires d’ouverture (texte) si disponibles. |
lat / lng | ?float | Coordonnées si disponibles. |
type | ?string | Type de hub chez le transporteur (par exemple ZRExpress "stopdesk"). |
raw | mixed | Élément natif. |
- Yalidine : les « centres » Yalidine.
communeIdest un filtre optionnel. - Maystro :
communeIdest obligatoire. Le stop desk propre à la commune a pouridl’id de la commune ; les points relais partenaires ont leur propre id. - ZRExpress :
POST /api/v1/hubs/searchavecisPickupPoint = true. Préférez la liste par wilaya seule : sicommuneIdne renvoie aucun bureau alors que la wilaya a des hubs, le SDK lèveNOT_FOUNDavecEMPTY_COMMUNE_HUBS. Utilisez l’UUID du hub renvoyé commestopDeskId.
3.7 listParcels(ParcelListQuery|array|null $query = null): ParcelListPage
Liste paginée des colis du compte avec leur status unifié actuel (sans historique : utilisez track() pour cela).
$page = $zr->listParcels(['page' => 1, 'pageSize' => 50]); foreach ($page->parcels as $parcel) { echo "{$parcel->trackingNumber} {$parcel->status}\n"; } // Pour synchroniser : // $zr->listParcels(['orderId' => 'ORD-1001']); // $zr->listParcels(['keyword' => '0550', 'status' => 'pending']); // $zr->listParcels(['updatedSince' => '2026-10-01T00:00:00+00:00', 'allPages' => true]);
| Champ | Notes |
|---|---|
page / pageSize | Par défaut 1 / 50 (ZRExpress plafonne à 1000). |
keyword | Texte libre (numéro de suivi, nom, téléphone…). |
orderId | Référence marchand (externalId chez ZRExpress). |
status | Statut unifié ; filtré après le mapping. |
updatedSince | ISO 8601. ZRExpress filtre sur lastStateUpdateAt. |
allPages | Suit la pagination (avec un plafond de sécurité). |
- ZRExpress :
POST /api/v1/parcels/search. - Maystro :
GET /api/stores/orders/(20 par page, fixe) ;status,orderId,keywordetupdatedSincefiltrent chaque page côté SDK. - Yalidine :
GET parcels/(jusqu’à 1000 par page) ;keyword→tracking(plusieurs numéros séparés par des virgules acceptés),orderId→order_id;statusetupdatedSince(date_last_status) filtrent chaque page côté SDK. Une page courte peut donc avoirhasNextàtrue: suivezhasNext, pas la taille de la page. - Ecotrack :
NOT_SUPPORTEDpour l’instant.
3.8 cancelParcel(string $trackingNumberOrId): void
$client->cancelParcel($parcel->trackingNumber);
- ZRExpress : accepte l’UUID ou le numéro de suivi.
- Yalidine : seuls les colis encore « En préparation » peuvent être supprimés ; sinon
VALIDATION_ERROR. - Maystro : accepte l’id de commande ou le numéro de suivi ; annule la commande (
PATCH /api/shared/status/{id}/, statut50).
3.9 healthCheck(): HealthCheckResult
Vérification légère et authentifiée (liste des wilayas). Appelle toujours le réseau (ignore le cache en mémoire). En cas de succès, renvoie { ok: true, provider, latencyMs } ; en cas d’échec, lève une ShippingError (AUTH_FAILED, NETWORK_ERROR…).
$health = $client->healthCheck(); // $health->ok === true // $health->provider // 'yalidine' | ... // $health->latencyMs // millisecondes
4. Statuts de colis
Chaque statut natif des transporteurs est converti en l’un de ces statuts unifiés :
| Statut | Signification |
|---|---|
pending | Enregistré mais pas encore confirmé / prêt pour l’enlèvement. |
created | Confirmé, prêt à être enlevé ou expédié. |
in_transit | Enlevé et en mouvement : hub, transfert, en cours de livraison, tentative échouée… |
delivered | Livré (COD encaissé). |
returned | Retourné ou en retour vers l’expéditeur. |
cancelled | Annulé / supprimé avant livraison. |
unknown | Pas encore mappé. Jamais une erreur : consultez $event->label / ->raw. |
Les constantes sont dans DzDelivery\Models\ParcelStatus (ParcelStatus::DELIVERED, …, ParcelStatus::ALL).
Pour convertir vous-même un statut natif (par exemple reçu par webhook), utilisez DzDelivery\Support\StatusMapper :
use DzDelivery\Support\StatusMapper; StatusMapper::map('yalidine', 'Sorti en livraison'); // 'in_transit' StatusMapper::maystro(41); // 'delivered' (code ou nom)
Détail des statuts et exemples par transporteur : Statuts de colis unifiés. Tables complètes natif → unifié : PROVIDERS.md.
5. Gestion des erreurs
Tout lève DzDelivery\Errors\ShippingError (qui étend \RuntimeException).
| Propriété | Type | Description |
|---|---|---|
code | string | Un des codes ci-dessous (une chaîne, pas un entier). |
getMessage() | string | Message du transporteur quand il existe. |
httpStatus | ?int | Statut HTTP de l’appel en échec. |
details | ?array | Par exemple les erreurs de validation [['field' => 'phone', 'message' => '…'], …]. |
raw | mixed | Corps d’erreur natif. |
provider | ?string | Clé du transporteur. |
retryAfter | ?int | RATE_LIMITED uniquement : secondes d’attente, issues de l’en-tête Retry-After quand il est envoyé. |
| Code | Quand |
|---|---|
AUTH_FAILED | Identifiants manquants ou invalides (401 / 403). |
VALIDATION_ERROR | Entrée invalide (vérifiée avant l’appel), 400 / 422, ou colis refusé par le transporteur. |
NOT_FOUND | 404, colis, wilaya ou commune inconnus. |
RATE_LIMITED | 429. Limites Yalidine par défaut : 5 req/s, 50/min, 1 000/h, 10 000/jour. |
NOT_SUPPORTED | Le transporteur ne propose pas cette opération. |
PROVIDER_ERROR | 5xx ou réponse inattendue. |
NETWORK_ERROR | Timeout, DNS, connexion refusée. |
UNKNOWN | Tout le reste. |
Le SDK ne relance jamais un appel de lui-même. Une relance simple pour les limites de débit :
use DzDelivery\Errors\ShippingError; function withRetry(callable $call, int $attempts = 3): mixed { for ($i = 1; ; $i++) { try { return $call(); } catch (ShippingError $e) { if ($e->code !== ShippingError::RATE_LIMITED || $i >= $attempts) { throw $e; } sleep($e->retryAfter ?? 2 ** $i); } } } $wilayas = withRetry(fn () => $client->getWilayas());
Ne relancez pas aveuglément createParcel sur NETWORK_ERROR : le colis existe peut-être déjà. Vérifiez d’abord, ou appuyez-vous sur votre orderId.
Afficher les erreurs de validation à l’utilisateur :
try { $client->createParcel($input); } catch (ShippingError $e) { if ($e->code === ShippingError::VALIDATION_ERROR) { foreach ($e->details ?? [] as $d) { $errors[$d['field'] ?? 'general'][] = $d['message']; } } else { error_log("[{$e->provider}] {$e->code}: {$e->getMessage()}"); throw $e; } }
6. Exemple Laravel
config/services.php :
'dz_delivery' => [ 'default' => env('DZ_DELIVERY_PROVIDER', 'yalidine'), 'zrexpress' => ['apiKey' => env('ZREXPRESS_API_KEY'), 'tenant' => env('ZREXPRESS_TENANT')], 'yalidine' => [ 'apiId' => env('YALIDINE_API_ID'), 'apiToken' => env('YALIDINE_API_TOKEN'), 'fromWilayaId' => (int) env('YALIDINE_FROM_WILAYA_ID', 16), ], 'maystro' => ['apiToken' => env('MAYSTRO_API_TOKEN')], ],
app/Providers/ShippingServiceProvider.php (à enregistrer dans bootstrap/providers.php ou config/app.php) :
namespace App\Providers; use DzDelivery\ClientFactory; use DzDelivery\Contracts\ShippingProviderInterface; use Illuminate\Support\ServiceProvider; final class ShippingServiceProvider extends ServiceProvider { public function register(): void { // Transporteur par défaut, injectable partout. Une instance par app = caches de localités partagés. $this->app->singleton(ShippingProviderInterface::class, function () { $provider = config('services.dz_delivery.default'); // [] si le transporteur n'a pas d'entrée de config : le SDK lève alors son AUTH_FAILED habituel, pas une TypeError return ClientFactory::create($provider, config("services.dz_delivery.$provider", [])); }); } }
Contrôleur :
use DzDelivery\Contracts\ShippingProviderInterface; use DzDelivery\Errors\ShippingError; final class ShipmentController { public function store(Order $order, ShippingProviderInterface $shipping) { try { $parcel = $shipping->createParcel([ 'recipientName' => $order->customer_name, 'phone' => $order->phone, 'address' => $order->address, 'wilayaId' => $order->wilaya_code, 'communeName' => $order->commune, 'productName' => $order->summary(), 'price' => $order->total, 'orderId' => (string) $order->id, ]); } catch (ShippingError $e) { return back()->withErrors(['shipping' => $e->getMessage()]); } $order->update(['tracking' => $parcel->trackingNumber, 'carrier' => $parcel->provider]); return back(); } }
Plusieurs transporteurs à la fois ? Liez plutôt une petite factory (par exemple fn (string $p) => ClientFactory::create($p, config("services.dz_delivery.$p", []))) et gardez un client par transporteur.
7. Webhooks Maystro
track() lit une commande Maystro et son historique à la demande. Pour être notifié au lieu d’interroger l’API, Maystro peut aussi pousser les changements de statut vers une URL HTTPS configurée dans l’application Maystro (Paramètres → Webhooks). L’événement utile est OrderStatusChanged, dont le payload vaut { "id": "<uuid de la commande>", "status": <code numérique> }. payload.id est l’id de commande renvoyé en $parcel->id par createParcel.
Ce que dit la documentation Maystro du corps de la requête (aucune requête réelle capturée pour l’instant : à considérer comme non vérifié) :
- l’événement JSON est encodé en base64 et envoyé dans
message.data; - le texte dit de le décoder « deux fois en base64 », alors que leur exemple Django ne le décode qu’une fois. L’exemple ci-dessous gère les deux cas.
<?php // public/maystro-webhook.php require __DIR__ . '/../vendor/autoload.php'; use DzDelivery\ClientFactory; use DzDelivery\Errors\ShippingError; // Secret partagé : déclarez l'URL /maystro-webhook.php?key=<MAYSTRO_WEBHOOK_SECRET> $secret = (string) getenv('MAYSTRO_WEBHOOK_SECRET'); $given = (string) ($_GET['key'] ?? ''); if ($secret === '' || !hash_equals($secret, $given)) { http_response_code(401); exit; } $body = json_decode(file_get_contents('php://input') ?: '', true); $data = $body['message']['data'] ?? null; $event = null; if (is_string($data)) { $decoded = base64_decode($data, true); $event = json_decode((string) $decoded, true); if (!is_array($event)) { // décodé une fois, c'est encore du base64 $event = json_decode((string) base64_decode((string) $decoded, true), true); } } if (!is_array($event)) { http_response_code(400); exit; } if (($event['event'] ?? null) === 'OrderStatusChanged') { $orderId = trim((string) ($event['payload']['id'] ?? '')); if ($orderId === '') { http_response_code(400); exit; } // Ne faites pas confiance au statut reçu : relisez la commande chez Maystro. $maystro = ClientFactory::create('maystro', ['apiToken' => getenv('MAYSTRO_API_TOKEN')]); try { $parcel = $maystro->track($orderId)->parcel; // accepte l'UUID de la commande } catch (ShippingError $e) { // commande inconnue ou Maystro indisponible http_response_code(502); // réponse non 2xx : l'événement n'est pas acquitté exit; } // mettre à jour votre commande dont carrier_id = $orderId avec $parcel->status ... } http_response_code(201);
La documentation ne décrit aucune signature des requêtes webhook, et le SDK n’a donc pas d’outil de vérification pour Maystro : protégez l’endpoint vous-même. L’exemple ci-dessus place un long secret aléatoire dans l’URL (/maystro-webhook.php?key=…) et le compare avec hash_equals avant de lire le corps de la requête. Un secret dans l’URL peut finir dans les logs du serveur ou d’un proxy : excluez la query string de vos logs pour cette route et changez le secret s’il fuit. Surtout, l’exemple ne croit pas le statut envoyé : il s’en sert seulement comme signal et relit la commande avec track(), si bien qu’un faux événement ne peut pas imposer un statut. StatusMapper::maystro() reste disponible si vous voulez convertir un code reçu sans appel réseau. Voir aussi les webhooks sur la page Maystro.
8. Tester votre intégration
Passez un handler Guzzle via l’option handler pour remplacer le réseau par des réponses préparées :
use DzDelivery\ClientFactory; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; $mock = new MockHandler([ new Response(200, ['Content-Type' => 'application/json'], json_encode([ 'has_more' => false, 'data' => [['id' => 16, 'name' => 'Alger', 'zone' => 1, 'is_deliverable' => 1]], ])), ]); $client = ClientFactory::create('yalidine', [ 'apiId' => 'test', 'apiToken' => 'test', 'fromWilayaId' => 16, 'handler' => HandlerStack::create($mock), ]); $wilayas = $client->getWilayas(); // servi par le mock, aucun appel HTTP
Ajoutez GuzzleHttp\Middleware::history($container) à la pile pour vérifier les requêtes envoyées. Des réponses natives réalistes pour chaque transporteur sont disponibles dans docs/fixtures/.
Dans le code applicatif, typez sur ShippingProviderInterface : vous pourrez alors injecter votre propre implémentation factice dans les tests au lieu de simuler HTTP.
9. Extras ZRExpress
La création / suppression en masse, les étiquettes et la vérification des webhooks signés sont disponibles sur ZRExpressProvider (et DzDelivery\Providers\ZRExpress\Webhook), pas sur ShippingProviderInterface. Présentation sur la page ZRExpress ; matrice complète et exemples dans ZREXPRESS.md.
use DzDelivery\Models\ClientOptions; use DzDelivery\Providers\ZRExpressProvider; use DzDelivery\Providers\ZRExpress\Webhook; $zr = new ZRExpressProvider(ClientOptions::fromArray([ 'apiKey' => getenv('ZREXPRESS_API_KEY'), 'tenant' => getenv('ZREXPRESS_TENANT'), ])); $bulk = $zr->createParcelsBulk([ // chaque élément DOIT contenir orderId (externalId) [...$parcelInput, 'orderId' => 'ORD-1'], ]); $labels = $zr->generateLabels(['16-AAA111-ZR']); // Webhooks : utilisez le corps brut de la requête (avant json_decode) $raw = file_get_contents('php://input'); $event = Webhook::verify($raw, getallheaders(), getenv('ZREXPRESS_WEBHOOK_SECRET')); // $event->type, $event->parcelId, $event->trackingNumber, $event->status
Pages transporteurs
Identifiants, particularités et exemples TypeScript / PHP pour chaque API.