sonnenglas/mydhl-php-sdk

Unofficial PHP SDK for MyDHL REST API (DHL Express)

Maintainers

Package info

github.com/sonnenglas/mydhl-php-sdk

pkg:composer/sonnenglas/mydhl-php-sdk

Transparency log

Statistics

Installs: 38 083

Dependents: 0

Suggesters: 0

Stars: 7

Open Issues: 1

3.0.0 2026-08-06 13:30 UTC

README

Unofficial PHP SDK for the DHL Express MyDHL REST API (currently aligned with spec 3.3.1, June 2026). That version is sent verbatim in the x-version header, which DHL requires on every call.

Status: CircleCI

Note: Only the modern REST API is supported. The legacy SOAP API is not.

Requirements

Installation

composer require sonnenglas/mydhl-php-sdk

Supported services

Service Supported
RATING
Retrieve Rates for a one-piece Shipment
Retrieve Rates for Multi-piece Shipments
Landed Cost
PRODUCT
Retrieve DHL Express products
SHIPMENT
Create Shipment
Customs / international shipments (export declaration)
Re-download archived shipment documents
Electronic Proof of Delivery
Add pieces to an existing shipment
Upload updated customs docs for shipment
Upload Commercial Invoice Data for shipment
TRACKING
Track a single DHL Express Shipment
Track multiple DHL Express Shipments (batch)
PICKUP
Create a DHL Express pickup booking request
Cancel a DHL Express pickup booking request
Update pickup information
Check whether a pickup was actually collected (via tracking)
IDENTIFIER
Allocate identifiers upfront
ADDRESS
Validate DHL Express pickup/delivery capability
INVOICE
Upload Commercial Invoice data
SERVICE POINTS / REFERENCE DATA
Look up servicepoints / reference data
EARLY SHIPMENT SCREENING
Screen break-bulk baby shipments

Design

The SDK splits responsibilities between value objects (immutable, validated request payloads) and services (thin transport that talk to DHL):

  • RateRequest, ShipmentRequest, PickupRequest, Pickup, ExportDeclaration, … — immutable inputs, validated in their constructors.
  • RateService::getRates(RateRequest) / getRatesForMultiPieceShipment(MultiPieceRateRequest) — return Rate[].
  • LandedCostService::getLandedCost(LandedCostRequest) — duties, taxes and fees before shipping.
  • ProductService::getProducts(ProductRequest) — available products without full pricing.
  • ShipmentService::createShipment(ShipmentRequest) — returns Shipment; plus addPieces(...), uploadImage(...), uploadInvoiceData(...) for existing shipments.
  • TrackingService::track(...) / trackBatch(...)
  • PickupService::book(...) / update(...) / cancel(...)
  • ImageService::getImages(...) — re-download archived customs/waybill PDFs.
  • ProofOfDeliveryService::getProofOfDelivery(...)
  • AddressService::validate(...) / isServiceable(...) — pickup/delivery capability checks.
  • IdentifierService::allocate(...) — pre-allocate waybill / invoice-reference identifiers.
  • InvoiceService::uploadInvoiceData(...) — standalone Commercial Invoice data upload.
  • ServicePointService::search(...) / findByAddress(...) / findByCoordinates(...)
  • ReferenceDataService::get(...) — country lists, service codes and other datasets.
  • EarlyShipmentScreeningService::screen(...) — pre-screen break-bulk baby shipments.

Every required field is a constructor parameter, so missing data fails at request-build time, not somewhere inside the API call.

Every getXService() call hands back the same instance for the lifetime of the MyDHL object, so getLastRawResponse() still returns the payload of the call you just made.

Error handling

HTTP and transport failures surface as Sonnenglas\MyDHL\Exceptions\ClientException — Guzzle exceptions never leak out of the SDK:

use Sonnenglas\MyDHL\Exceptions\ClientException;

try {
    $rates = $myDhl->getRateService()->getRates($request);
} catch (ClientException $e) {
    $e->getStatusCode();   // int, or null when DHL never answered (DNS, timeout, TLS)
    $e->getCode();         // same status code, 0 when there was no response
    $e->getResponseBody(); // full raw body, including DHL's `additionalDetails` problem list
    $e->getPrevious();     // the original Guzzle exception
}

getMessage() carries the method, the URI and the response body, truncated at 2 000 characters so it stays loggable — reach for getResponseBody() when you need the whole payload. Credentials are sent as an Authorization header and never appear in the message.

Quick start

use Sonnenglas\MyDHL\MyDHL;

$myDhl = new MyDHL(
    username: getenv('DHL_EXPRESS_USERNAME'),
    password: getenv('DHL_EXPRESS_PASSWORD'),
    testMode: true, // false → production
);

The base URLs are baked in:

Environment URL
Sandbox https://express.api.dhl.com/mydhlapi/test/
Production https://express.api.dhl.com/mydhlapi/

Sandbox is rate-limited to 500 calls/day per credential set.

Usage

Retrieve rates

use DateTimeImmutable;
use Sonnenglas\MyDHL\ValueObjects\Package;
use Sonnenglas\MyDHL\ValueObjects\RateAddress;
use Sonnenglas\MyDHL\ValueObjects\RateRequest;

$request = new RateRequest(
    accountNumber: '99999999',
    originAddress: new RateAddress(
        countryCode: 'DE',
        postalCode: '10117',
        cityName: 'Berlin',
    ),
    destinationAddress: new RateAddress(
        countryCode: 'DE',
        postalCode: '20099',
        cityName: 'Hamburg',
    ),
    package: new Package(weight: 10, height: 20, length: 10, width: 30),
    shippingDate: new DateTimeImmutable('tomorrow'),
);

$rates = $myDhl->getRateService()->getRates($request);

Multi-piece rates

For shipments with more than one package, use the POST variant:

use Sonnenglas\MyDHL\ValueObjects\MultiPieceRateRequest;

$rates = $myDhl->getRateService()->getRatesForMultiPieceShipment(new MultiPieceRateRequest(
    originAddress: new RateAddress(countryCode: 'DE', postalCode: '10117', cityName: 'Berlin'),
    destinationAddress: new RateAddress(countryCode: 'DE', postalCode: '20099', cityName: 'Hamburg'),
    accounts: [new Account(typeCode: 'shipper', number: '123456789')],
    packages: [
        new Package(weight: 5, height: 20, length: 10, width: 30),
        new Package(weight: 2, height: 10, length: 10, width: 20),
    ],
    plannedShippingDateAndTime: new DateTimeImmutable('tomorrow 14:00'),
));

Landed cost

Estimate duties, taxes and fees for an international shipment before creating it:

use Sonnenglas\MyDHL\ValueObjects\LandedCostRequest;
use Sonnenglas\MyDHL\ValueObjects\LandedCostRequestItem;

$result = $myDhl->getLandedCostService()->getLandedCost(new LandedCostRequest(
    originAddress: new RateAddress(countryCode: 'DE', postalCode: '10117', cityName: 'Berlin'),
    destinationAddress: new RateAddress(countryCode: 'US', postalCode: '10001', cityName: 'New York'),
    accounts: [new Account(typeCode: 'shipper', number: '123456789')],
    packages: [new Package(weight: 2, height: 10, length: 15, width: 20)],
    items: [new LandedCostRequestItem(
        number: 1,
        quantity: 1,
        unitPrice: 50.0,
        unitPriceCurrencyCode: 'EUR',
        manufacturerCountry: 'DE',
        name: 'Solar jar',
        commodityCode: '940541',
        weight: 2.0,
        weightUnitOfMeasurement: 'metric',
    )],
    currencyCode: 'EUR',
));

$landedCost = $result->getFirstProduct();

if ($landedCost !== null) {
    echo $landedCost->totalPrice, ' ', $landedCost->currency, "\n";
    echo $landedCost->getTotalDuties(), ' duties, ', $landedCost->getTotalTaxes(), " taxes\n";
}

LandedCostResult::$products holds one LandedCost per DHL product; $warnings collects anything DHL flagged about the quote.

Available products

Like /rates, but without the full price breakdown — useful for showing which DHL products serve a lane:

use Sonnenglas\MyDHL\ValueObjects\ProductRequest;

$products = $myDhl->getProductService()->getProducts(new ProductRequest(
    accountNumber: '123456789',
    originAddress: new RateAddress(countryCode: 'DE', postalCode: '10117', cityName: 'Berlin'),
    destinationAddress: new RateAddress(countryCode: 'DE', postalCode: '20099', cityName: 'Hamburg'),
    package: new Package(weight: 5, height: 20, length: 10, width: 30),
    shippingDate: new DateTimeImmutable('tomorrow'),
));

Validate an address

use Sonnenglas\MyDHL\ValueObjects\AddressValidateRequest;

$serviceable = $myDhl->getAddressService()->isServiceable(new AddressValidateRequest(
    type: 'delivery', // or 'pickup'
    countryCode: 'DE',
    postalCode: '10117',
    cityName: 'Berlin',
));

Allocate identifiers upfront

use Sonnenglas\MyDHL\Services\IdentifierService;

$identifiers = $myDhl->getIdentifierService()->allocate(
    accountNumber: '123456789',
    type: IdentifierService::TYPE_SID,
    size: 5,
);

IdentifierService::TYPES lists the codes DHL accepts: SID (shipment), PID (piece), ASID3 / ASID6 / ASID12 / ASID24 (alternative shipment identifiers) and HUID (handling unit). Anything else throws InvalidArgumentException before the call is made.

Create a domestic shipment

use DateTimeImmutable;
use Sonnenglas\MyDHL\ValueObjects\Account;
use Sonnenglas\MyDHL\ValueObjects\Address;
use Sonnenglas\MyDHL\ValueObjects\Contact;
use Sonnenglas\MyDHL\ValueObjects\Incoterm;
use Sonnenglas\MyDHL\ValueObjects\Package;
use Sonnenglas\MyDHL\ValueObjects\Pickup;
use Sonnenglas\MyDHL\ValueObjects\ShipmentRequest;

$request = new ShipmentRequest(
    plannedShippingDateAndTime: new DateTimeImmutable('tomorrow 14:00'),
    productCode: 'N',
    shipperAddress: new Address(
        addressLine1: 'Karl-Liebknecht-Straße 13',
        countryCode: 'DE',
        postalCode: '10178',
        cityName: 'Berlin',
    ),
    shipperContact: new Contact(
        phone: '+49301234567',
        companyName: 'Acme Lab',
        fullName: 'John Shipper',
        email: 'shipper@example.com',
    ),
    receiverAddress: new Address(
        addressLine1: 'Hamburger Str. 1',
        countryCode: 'DE',
        postalCode: '20099',
        cityName: 'Hamburg',
    ),
    receiverContact: new Contact(
        phone: '+49401234567',
        companyName: 'Acme Hamburg',
        fullName: 'Jane Receiver',
        email: 'receiver@example.com',
    ),
    accounts: [new Account(typeCode: 'shipper', number: '123456789')],
    packages: [new Package(weight: 5, height: 20, length: 10, width: 30)],
    pickup: Pickup::notRequested(),
    description: 'Glass jars with embedded solar panel',
    incoterm: new Incoterm('DAP'),
);

$shipment = $myDhl->getShipmentService()->createShipment($request);
file_put_contents('label.pdf', $shipment->getLabelPdf());

description (1–70 characters) and incoterm are required on every shipment, domestic ones included — not just customs-declarable ones. Both are validated in the ShipmentRequest constructor, so an omitted value fails locally instead of coming back as a 422.

Customs / international shipments

International shipments need declaredValue, an ExportDeclaration with line items, and (recommended) a tax RegistrationNumber:

use Sonnenglas\MyDHL\ValueObjects\CustomerReference;
use Sonnenglas\MyDHL\ValueObjects\ExportDeclaration;
use Sonnenglas\MyDHL\ValueObjects\Invoice;
use Sonnenglas\MyDHL\ValueObjects\LineItem;
use Sonnenglas\MyDHL\ValueObjects\OutputImageProperties;
use Sonnenglas\MyDHL\ValueObjects\RegistrationNumber;

$request = new ShipmentRequest(
    // …same shipper / receiver / packages as above…
    productCode: 'P', // EXPRESS WORLDWIDE
    isCustomsDeclarable: true,
    incoterm: new Incoterm('DAP'),
    shipperRegistrationNumbers: [
        new RegistrationNumber(
            typeCode: RegistrationNumber::TYPE_VAT,
            number: 'DE123456789',
            issuerCountryCode: 'DE',
        ),
    ],
    customerReferences: [
        new CustomerReference(value: 'PO-12345', typeCode: CustomerReference::TYPE_PURCHASE_ORDER),
    ],
    declaredValue: 50.0,
    declaredValueCurrency: 'EUR',
    exportDeclaration: new ExportDeclaration(
        lineItems: [new LineItem(
            number: 1,
            description: 'Glass jar with embedded solar panel',
            price: 50.0,
            quantityValue: 1,
            quantityUnit: LineItem::UNIT_PIECES,
            manufacturerCountry: 'DE',
            netWeight: 2.0,
            grossWeight: 2.5,
            exportReasonType: LineItem::REASON_PERMANENT,
            commodityCode: '940541', // HS code — speeds up customs clearance
        )],
        invoice: new Invoice(
            number: 'INV-1001',
            date: new DateTimeImmutable('today'),
        ),
    ),
    outputImageProperties: new OutputImageProperties(
        printerDPI: 300,
        encodingFormat: OutputImageProperties::ENCODING_PDF,
    ),
);

The incoterm sits on the ShipmentRequest here. When the same goods are sent to upload-invoice-data, DHL expects it inside the declaration instead — that endpoint uses a different schema.

Track a shipment

$tracked = $myDhl->getTrackingService()->track('1234567890');

if ($tracked !== null) {
    echo $tracked->status, "\n";
    foreach ($tracked->events as $event) {
        echo $event->date, ' ', $event->time, '', $event->description, "\n";
    }
}

// Batch — up to TrackingService::MAX_BATCH_SIZE (200) waybills per call.
$tracked = $myDhl->getTrackingService()->trackBatch([
    '1234567890', '0987654321',
]);

track() asks DHL for the GMT offset of every scan, so TrackingEvent::getOccurredAt() returns a correctly zoned timestamp. The batch endpoint has no such option — its events carry no offset, so use track() when event times matter.

Check whether a pickup actually happened

MyDHL has no read endpoint for pickups — /pickups only accepts POST, PATCH and DELETE. A dispatch confirmation number therefore proves that the booking was accepted, not that a courier ever showed up. The scans are the only evidence:

use Sonnenglas\MyDHL\ValueObjects\PickupStatus;

$status = $myDhl->getTrackingService()->getPickupStatus('1234567890');

match ($status) {
    PickupStatus::Collected    => 'courier collected it',
    PickupStatus::Scanned      => 'in the network, but no pickup scan',
    PickupStatus::NotCollected => 'DHL never saw the parcel',
    null                       => 'DHL does not know this waybill',
};

NotCollected is the one worth alerting on: a booked pickup with no scan hours later means the parcel is still sitting in the warehouse. The same answer is available on the shipment itself, together with the scan behind it:

$tracked = $myDhl->getTrackingService()->track('1234567890');

$tracked->getPickupStatus();               // PickupStatus
$tracked->getPickedUpAt();                 // ?DateTimeImmutable
$tracked->getPickupEvent()?->description;  // 'Shipment picked up'
$tracked->hasAnyScan();

Piece-level scans count too — DHL does not always mirror them onto the shipment.

Book / update / cancel a courier pickup separately

Use this when the shipment was created with Pickup::notRequested() and the pickup needs to be booked (or cancelled) independently — typical when an order is cancelled hours before pickup time.

use Sonnenglas\MyDHL\ValueObjects\PickupRequest;
use Sonnenglas\MyDHL\ValueObjects\PickupShipmentSummary;

$booking = $myDhl->getPickupService()->book(new PickupRequest(
    plannedPickupDateAndTime: new DateTimeImmutable('+1 day 14:00'),
    accounts: [new Account('shipper', '123456789')],
    shipperAddress: $shipperAddress,
    shipperContact: $shipperContact,
    shipmentDetails: [new PickupShipmentSummary(
        productCode: 'N',
        isCustomsDeclarable: false,
        packages: [new Package(weight: 5, height: 20, length: 10, width: 30)],
    )],
    closeTime: '18:00',
    location: 'reception',
    locationType: PickupRequest::LOCATION_BUSINESS,
));

$myDhl->getPickupService()->cancel(
    dispatchConfirmationNumber: $booking->getFirstConfirmationNumber(),
    requestorName: 'John Smith',
    reason: 'wrongdate',
);

Moving a booking to another slot does not need a cancel + re-book — update() keeps the confirmation number. DHL replaces the booking with what you send, so pass a complete PickupRequest plus the account the pickup was originally booked with:

use Sonnenglas\MyDHL\ValueObjects\UpdatePickupRequest;

$update = $myDhl->getPickupService()->update(new UpdatePickupRequest(
    dispatchConfirmationNumber: 'PRG999126012345',
    originalShipperAccountNumber: '123456789',
    pickup: new PickupRequest(
        plannedPickupDateAndTime: new DateTimeImmutable('+1 day 09:00'),
        // … same fields as the original booking, with whatever changed
    ),
));

echo $update->dispatchConfirmationNumber;

plannedPickupDateAndTime must be in the future and at most 10 days ahead — DHL rejects anything outside that window.

Re-download archived documents (waybill, customs invoice)

use Sonnenglas\MyDHL\Services\ImageService;

$documents = $myDhl->getImageService()->getImages(
    shipmentTrackingNumber: '1234567890',
    shipperAccountNumber: '123456789',
    typeCodes: [ImageService::TYPE_WAYBILL, ImageService::TYPE_COMMERCIAL_INVOICE],
    pickupYearAndMonth: '2026-05',
);

foreach ($documents as $doc) {
    file_put_contents("{$doc->typeCode}.pdf", $doc->content);
}

/get-image does not return the transport label. The label is returned inline only at createShipment time. Save Shipment::getLabelPdf() then.

Modify an existing shipment (add pieces, upload customs docs / invoice data)

All three operations target a shipment that already exists; DHL enables them per customer:

use Sonnenglas\MyDHL\ValueObjects\AddPieceRequest;
use Sonnenglas\MyDHL\ValueObjects\DocumentImage;
use Sonnenglas\MyDHL\ValueObjects\UploadImageRequest;
use Sonnenglas\MyDHL\ValueObjects\UploadInvoiceDataRequest;

$shipmentService = $myDhl->getShipmentService();

// Add a piece to a not-yet-collected shipment
$added = $shipmentService->addPieces('1234567890', new AddPieceRequest(
    originalPlannedShippingDate: new DateTimeImmutable('tomorrow'),
    productCode: 'N',
    accounts: [new Account(typeCode: 'shipper', number: '123456789')],
    packages: [new Package(weight: 1, height: 10, length: 10, width: 10)],
));

// Upload updated customs paperwork (base64 handled internally)
$shipmentService->uploadImage('1234567890', new UploadImageRequest(
    originalPlannedShippingDate: new DateTimeImmutable('tomorrow'),
    productCode: 'P',
    accounts: [new Account(typeCode: 'shipper', number: '123456789')],
    documentImages: [new DocumentImage(content: (string) file_get_contents('invoice.pdf'))],
));

// Upload Commercial Invoice data for the shipment (requires the PM service code)
$shipmentService->uploadInvoiceData('1234567890', new UploadInvoiceDataRequest(
    exportDeclarations: [$invoiceDataDeclaration],
    currency: 'EUR',
));

The standalone variant (before the shipment exists) lives on InvoiceService:

$myDhl->getInvoiceService()->uploadInvoiceData(new UploadInvoiceDataRequest(
    exportDeclarations: [$invoiceDataDeclaration],
    currency: 'EUR',
    shipmentTrackingNumber: '1234567890',
    plannedShipDate: new DateTimeImmutable('tomorrow'),
    accounts: [new Account(typeCode: 'shipper', number: '123456789')],
));

The invoice-upload declaration is not the shipment declaration

DHL uses two different schemas for exportDeclaration, and both reject unknown fields. An ExportDeclaration built for createShipment is therefore not reusable here: uploading invoice data additionally requires an incoterm on the declaration and a function on the invoice, while the invoice's signatureName, signatureTitle, totalNetWeight and totalGrossWeight are only accepted by createShipment.

The SDK keeps both shapes in one pair of value objects and picks the right serialization per endpoint, so you only have to supply the extra fields:

$invoiceDataDeclaration = new ExportDeclaration(
    lineItems: [new LineItem(
        number: 1,
        description: 'Glass jar with embedded solar panel',
        price: 50.0,
        quantityValue: 1,
        quantityUnit: LineItem::UNIT_PIECES,
        manufacturerCountry: 'DE',
        netWeight: 2.0,
        grossWeight: 2.5,
        exportReasonType: LineItem::REASON_PERMANENT,
    )],
    invoice: new Invoice(
        number: 'INV-1001',
        date: new DateTimeImmutable('today'),
        function: Invoice::FUNCTION_EXPORT, // import | export | both — required here
    ),
    incoterm: new Incoterm('DAP'),          // required here, top-level on createShipment
);

Both extra arguments are optional on the constructor (so shipment declarations stay unchanged) but validated when the upload request is built, which means a missing one fails locally instead of coming back as a 422.

Service points

$servicePoints = $myDhl->getServicePointService()->findByAddress('Friedrichstraße 155, Berlin', 'DE');

foreach ($servicePoints as $servicePoint) {
    echo $servicePoint->servicePointName, '', $servicePoint->distance, "\n";
}

search(ServicePointRequest) exposes the full query surface (geo radius, capabilities, opening hours, …).

Reference data

use Sonnenglas\MyDHL\Services\ReferenceDataService;

$dataset = $myDhl->getReferenceDataService()->getDataset(ReferenceDataService::DATASET_COUNTRY);

$germany = $dataset?->findRow('countryCode', 'DE');

getDataset() returns null when DHL has no rows for the dataset; use get() instead when you want the full ReferenceDataResult wrapper. ReferenceDataService::ALLOWED_DATASETS lists every dataset DHL publishes (countries, currencies, service codes, …). Rows are returned as raw associative arrays because each dataset has its own schema.

Early shipment screening

Pre-screen break-bulk baby shipments before tendering them:

use Sonnenglas\MyDHL\ValueObjects\EarlyShipmentScreeningRequest;

$screening = $myDhl->getEarlyShipmentScreeningService()->screen(new EarlyShipmentScreeningRequest(
    plannedShippingDateAndTime: new DateTimeImmutable('tomorrow 14:00'),
    productCode: 'P',
    shipperAddress: $shipperAddress,
    shipperContact: $shipperContact,
    receiverAddress: $receiverAddress,
    receiverContact: $receiverContact,
    accounts: [new Account(typeCode: 'shipper', number: '123456789')],
));

if ($screening->isGreen()) {
    // safe to tender
}

Proof of Delivery

$pods = $myDhl->getProofOfDeliveryService()->getProofOfDelivery(
    shipmentTrackingNumber: '1234567890',
    shipperAccountNumber: '123456789',
);

Full examples:

Development

composer install
composer test              # unit tests (PHPUnit)
composer test:integration  # live sandbox tests (require DHL_EXPRESS_* env vars)
composer phpstan           # PHPStan level 9
composer lint              # PHP-CS-Fixer (dry run)
composer lint:fix          # PHP-CS-Fixer (apply fixes)

Integration tests against the DHL sandbox

Copy tests/Integration/.env.example and export your sandbox credentials, then:

DHL_EXPRESS_USERNAME=… \
DHL_EXPRESS_PASSWORD=… \
DHL_EXPRESS_ACCOUNT_NUMBER=… \
composer test:integration

Without these env vars the integration suite auto-skips, so contributor laptops and CI without secrets stay green. Each integration run consumes one or two of the daily 500 sandbox calls — keep them deliberate.

Upgrading

  • From 2.x → 3.0: see UPGRADE-2.x-to-3.0.md. Covers every remaining endpoint of spec 3.3.1 and realigns the existing payloads with it: failed requests now throw the SDK's own ClientException instead of a Guzzle exception, service getters return a shared instance, description and incoterm are required on every shipment, the RegistrationNumber and CustomerReference type codes are validated against DHL's enums (several constants changed value), and trackBatch() enforces the 200-waybill limit.
  • From 1.x → 2.0: see UPGRADE-1.x-to-2.0.md. Most callers only need to update the Shipment response getters that became nullable; international shipments need the new ExportDeclaration / declaredValue arguments.
  • From 0.x → 1.0: the fluent setter API on services was replaced by immutable Request VOs. See the 1.0 release notes.

Credits

Built and maintained by Przemek Peron.

License

MIT