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
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) :

composer.json
{
  "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.

PHP
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)
]);
TransporteurClés requisesNotes
zrexpressapiKey, tenant—
yalidineapiId, apiTokenPlus 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.
maystroapiTokenToken de la boutique. Une valeur commençant déjà par Token est envoyée telle quelle.
ecotracktoken + exactement un de courier / baseUrlUn 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.
noestapiToken, userGuidTous 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().
ecomapiKey, apiTokenKEY 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.
mdmapiKey (+ 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.
elogistiaapiKeyemail 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)

OptionTypeDéfautDescription
baseUrlstringURL de production du transporteurRemplace l’URL de l’API (proxy, préproduction).
timeoutMsint15000Délai de connexion + requête.
includeRawbooltrueAttache la réponse native du transporteur en ->raw sur les modèles renvoyés. false pour garder des objets légers.
handlercallable—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éthodeZRExpressYalidineMaystroEcotrackNoESTE-comMDMElogistia
createParcelouiouiouioui (pending jusqu’à validation, voir autoValidate)oui (idem, autoValidate)oui (idem, autoValidate)oui (produits du catalogue MDM, storeId)oui
trackouiouioui (ou webhooks)ouiouiouiouioui
getFeesouiouiouioui (par wilaya)oui (par wilaya)oui (par wilaya)oui (par commune)oui (par wilaya)
getWilayas / getCommunesouiouiouiouiouiouiouioui
getStopDesksoui (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)
listParcelsoui (parcels/search)oui (GET parcels/, status / updatedSince côté SDK)oui (20 par page, filtres côté SDK)NOT_SUPPORTED (bientôt)NOT_SUPPORTEDoui (GET /colis)oui (parcels/search)NOT_SUPPORTED
cancelParcelouioui (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_SUPPORTEDNOT_SUPPORTED
healthCheckouiouiouiouiouiouiouioui

3.1 createParcel(ParcelInput|array $input): Parcel

Livraison à domicile :

PHP
$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.

PHP
$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) :

ChampTypeRequisNotes
recipientNamestringouiNom complet. Yalidine le coupe au premier espace en prénom / nom.
phonestringouiTout 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.
phone2stringnonSecond numéro.
addressstringouiTexte libre.
wilayaIdint|stringl’un des deuxCode officiel 1–58 ou Location->id du transporteur.
wilayaNamestringl’un des deuxRésolu sans tenir compte de la casse ni des accents.
communeIdint|stringl’un des deuxLocation->id du transporteur, issu de getCommunes.
communeNamestringl’un des deuxRésolu dans la wilaya.
productNamestringl’un des deuxDescription d’un seul produit.
productListarrayl’un des deux[['name' => string, 'quantity' => int ≥ 1, 'unitPrice' => ?number, 'sku' => ?string], …].
pricenumberouiMontant 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.
weightnumbernonkg.
length, width, heightnumbernoncm. 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.
isStopDeskboolnonfalse par défaut (livraison à domicile).
stopDeskIdstringsi isStopDeskId du bureau renvoyé par getStopDesks (ZRExpress : l’UUID du hub).
stockType?stringnonZRExpress uniquement : "none" (défaut) | "local" | "warehouse". Surcharge par article via productList[].stockType ; local / warehouse exigent productId + sku.
remarkstringnonNote pour le livreur (envoyée à Maystro uniquement, pas à ZRExpress ni à Yalidine).
orderIdstringnonVotre 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éTypeDescription
idstringId chez le transporteur (UUID chez ZRExpress / Maystro ; Yalidine : le numéro de suivi). À utiliser pour cancelParcel.
trackingNumberstringNuméro de suivi à montrer au client et à passer à track.
statusstringStatut unifié (voir §4).
providerstringClé du transporteur (zrexpress, yalidine, maystro…).
labelUrl?stringURL du bordereau quand le transporteur la renvoie avec le colis (Yalidine).
rawmixedRé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

PHP
$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éTypeDescription
parcelParcelÉtat actuel (voir ci-dessus).
eventsTrackingEvent[]Historique, du plus ancien au plus récent. Peut être vide.

TrackingEvent :

PropriétéTypeDescription
statusstringStatut unifié.
labelstringLibellé natif, par exemple Tentative échouée (Client ne répond pas).
atstringDate ISO 8601 avec décalage horaire, par exemple 2025-12-30T13:24:34+01:00.
rawmixedÉ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

PHP
$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) :

ChampTypeRequisNotes
toWilayaIdint|stringouiCode 1–58 ou id du transporteur.
toCommuneIdint|stringnonYalidine / Maystro : sans lui, la première commune de la wilaya est utilisée.
fromWilayaIdint|stringnonYalidine : remplace le fromWilayaId du client.
weightnumbernonkg.
length, width, heightnumbernoncm. Utilisés par Yalidine uniquement.
isStopDeskboolnontrue : deliveryFee est le tarif stop desk.

Sortie (FeeQuote) :

PropriétéTypeDescription
deliveryFeeint|floatTarif pour le mode demandé (domicile ou stop desk).
stopDeskFeeint|float|nullTarif stop desk, quand le transporteur le fournit.
returnFeeint|float|nullFrais de retour (Yalidine uniquement pour l’instant).
currencystringToujours DZD.
rawmixedRé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[]

PHP
foreach ($client->getWilayas() as $wilaya) {
    echo "{$wilaya->code} {$wilaya->name} ({$wilaya->id})\n";
}

3.5 getCommunes(int|string $wilayaId): Location[]

PHP
$communes = $client->getCommunes(16);       // code, id transporteur ou (Yalidine/Maystro) nom

Sortie (Location, pour les deux méthodes) :

PropriétéTypeDescription
idstringId du transporteur : à passer en wilayaId / communeId. ZRExpress utilise des UUID ; Yalidine et Maystro des nombres (id de wilaya = code officiel).
namestringNom (français / latin).
parentId?stringCommunes : l’id de la wilaya.
code?stringWilayas : code officiel 1–58. Communes : code postal quand il est connu (Maystro).
rawmixedÉ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.

PHP
// 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) :

ChampTypeRequisNotes
wilayaIdint|stringouiCode 1–58, id du transporteur ou nom.
communeIdint|stringMaystro : oui ; autres : nonId du transporteur ou nom. Filtre optionnel pour Yalidine / ZRExpress / Ecotrack.

Sortie (StopDesk[]) :

PropriétéTypeDescription
idstringÀ passer en stopDeskId.
namestringNom du bureau.
address?stringQuand le transporteur la fournit.
communeId?stringCommune du bureau.
wilayaId?stringWilaya du bureau.
phone?stringTéléphone principal si disponible.
openingHours?stringHoraires d’ouverture (texte) si disponibles.
lat / lng?floatCoordonnées si disponibles.
type?stringType de hub chez le transporteur (par exemple ZRExpress "stopdesk").
rawmixedÉlément natif.
  • Yalidine : les « centres » Yalidine. communeId est un filtre optionnel.
  • Maystro : communeId est obligatoire. Le stop desk propre à la commune a pour id l’id de la commune ; les points relais partenaires ont leur propre id.
  • ZRExpress : POST /api/v1/hubs/search avec isPickupPoint = true. Préférez la liste par wilaya seule : si communeId ne renvoie aucun bureau alors que la wilaya a des hubs, le SDK lève NOT_FOUND avec EMPTY_COMMUNE_HUBS. Utilisez l’UUID du hub renvoyé comme stopDeskId.

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).

PHP
$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]);
ChampNotes
page / pageSizePar défaut 1 / 50 (ZRExpress plafonne à 1000).
keywordTexte libre (numéro de suivi, nom, téléphone…).
orderIdRéférence marchand (externalId chez ZRExpress).
statusStatut unifié ; filtré après le mapping.
updatedSinceISO 8601. ZRExpress filtre sur lastStateUpdateAt.
allPagesSuit 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, keyword et updatedSince filtrent 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 ; status et updatedSince (date_last_status) filtrent chaque page côté SDK. Une page courte peut donc avoir hasNext à true : suivez hasNext, pas la taille de la page.
  • Ecotrack : NOT_SUPPORTED pour l’instant.

3.8 cancelParcel(string $trackingNumberOrId): void

PHP
$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}/, statut 50).

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…).

PHP
$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 :

StatutSignification
pendingEnregistré mais pas encore confirmé / prêt pour l’enlèvement.
createdConfirmé, prêt à être enlevé ou expédié.
in_transitEnlevé et en mouvement : hub, transfert, en cours de livraison, tentative échouée…
deliveredLivré (COD encaissé).
returnedRetourné ou en retour vers l’expéditeur.
cancelledAnnulé / supprimé avant livraison.
unknownPas 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 :

PHP
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éTypeDescription
codestringUn des codes ci-dessous (une chaîne, pas un entier).
getMessage()stringMessage du transporteur quand il existe.
httpStatus?intStatut HTTP de l’appel en échec.
details?arrayPar exemple les erreurs de validation [['field' => 'phone', 'message' => '…'], …].
rawmixedCorps d’erreur natif.
provider?stringClé du transporteur.
retryAfter?intRATE_LIMITED uniquement : secondes d’attente, issues de l’en-tête Retry-After quand il est envoyé.
CodeQuand
AUTH_FAILEDIdentifiants manquants ou invalides (401 / 403).
VALIDATION_ERROREntrée invalide (vérifiée avant l’appel), 400 / 422, ou colis refusé par le transporteur.
NOT_FOUND404, colis, wilaya ou commune inconnus.
RATE_LIMITED429. Limites Yalidine par défaut : 5 req/s, 50/min, 1 000/h, 10 000/jour.
NOT_SUPPORTEDLe transporteur ne propose pas cette opération.
PROVIDER_ERROR5xx ou réponse inattendue.
NETWORK_ERRORTimeout, DNS, connexion refusée.
UNKNOWNTout le reste.

Le SDK ne relance jamais un appel de lui-même. Une relance simple pour les limites de débit :

PHP
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 :

PHP
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 :

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) :

app/Providers/ShippingServiceProvider.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 :

PHP
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.
public/maystro-webhook.php
<?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 :

PHP
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.

PHP
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.