mvdnbrk/laravel-dhlparcel
Integrates the DHL Parcel API into Laravel so you can create shipments, generate shipping labels, and track parcels directly from your app via a clean, fluent, and testable interface that makes adding DHL Parcel delivery options simple.
Install and wire it up
composer require mvdnbrk/laravel-dhlparcel
php artisan vendor:publish --provider="Mvdnbrk\DhlParcel\DhlParcelServiceProvider"
The package is auto-discovered; publishing gives you config/dhlparcel.php. Open that file first — it is the single source of truth for credential key names, base URL, and any default label options. Expect environment-driven values along the lines of:
DHL_PARCEL_USER_ID=your-user-id
DHL_PARCEL_API_KEY=your-api-key
DHL Parcel's API authenticates with a userId + apiKey header pair rather than OAuth, so there is no token dance to manage — which also means a missing/incorrect key fails immediately with a 401 rather than a silent degradation. Get sandbox credentials from the DHL Parcel developer portal and verify the base URL in the config points at the sandbox host before you write a line of business logic.
Where to look first
config/dhlparcel.php — credential names and endpoint host.src/ facade + client class — this is a thin wrapper, so the method signatures there are the documentation. Read them before trusting any blog post.First use case: label on order fulfilment
The canonical integration is "admin marks order as shipped → create a DHL shipment → store the label PDF → attach tracking number to the order." Prove the loop end-to-end with a hardcoded test order before touching your domain model:
use Mvdnbrk\DhlParcel\Facades\DhlParcel; // verify exact facade namespace in src/
$shipment = DhlParcel::createShipment([
'receiver' => [
'name' => 'Test Receiver',
'address' => ['countryCode' => 'NL', 'postalCode' => '1012AB', 'city' => 'Amsterdam', 'street' => 'Dam', 'number' => '1'],
],
'pieces' => [['weight' => 1000, 'dimensions' => ['length' => 200, 'width' => 150, 'height' => 100]]],
]);
Then immediately fetch the label for that shipment and confirm you get bytes back, not an error envelope. That single round trip surfaces 90% of the integration pain (units, address validation, auth, label options).
Wrap it, don't sprinkle it
Never call the facade directly from controllers. Put a DhlParcelService (or a CreateDhlShipment action) between your app and the package. The package is small, unmaintained-ish, and its array payloads will change shape over time; one wrapper class means one place to patch when DHL revises an endpoint or you swap to the official SDK.
final class DhlParcelService
{
public function createLabelForOrder(Order $order): ShipmentLabel
{
$response = DhlParcel::createShipment($this->mapOrderToPayload($order));
$label = DhlParcel::getLabel($response->id, ['format' => 'pdf', 'printer' => 'A4']);
$path = "labels/{$order->id}.pdf";
Storage::disk('s3')->put($path, base64_decode($label->pdf)); // verify response field
return new ShipmentLabel($path, $response->trackerCode);
}
}
Queue it, with idempotency
Creating a shipment is a non-idempotent POST. A timeout or a queue retry can produce two physical labels and two billable shipments. Guard with a dhl_shipment_id column on the order and short-circuit if it is already set; only retry on connection failures, never on 5xx responses you haven't inspected.
public function handle(Order $order): void
{
if ($order->dhl_tracker_code) {
return; // already created — do not re-POST
}
// ...create + persist within a transaction on your side
}
Persist the tracker code, stream the label
Store the barcode/tracker code on the order row so tracking lookups never depend on the DHL API. Store the PDF on a filesystem disk and keep only the path in the DB — base64 blobs in MySQL will bloat your backups fast.
Tracking sync as a scheduled command
// app/Console/Commands/SyncDhlTracking.php
Schedule::command('dhl:sync-tracking')->hourly()->withoutOverlapping();
Iterate recent shipments, call the tracker endpoint, and write a status transition only when it changes. DHL tracking responses are eventually consistent and often repeat the same status for days — diff before you fire customer notifications, or you'll email people five times about "in transit."
Testing
The facade is bound in the container, so swap the underlying client in tests rather than hitting the network:
$this->mock(DhlParcelClient::class, function ($mock) {
$mock->shouldReceive('createShipment')->once()->andReturn((object) ['id' => 'abc', 'trackerCode' => '3SDHL123']);
});
If the package uses Laravel's HTTP client internally (check src/), Http::fake() with a recorded fixture response is the cleanest route and keeps the payload-mapping code under test too. Add at least one fixture of a failed label request — the error body is where the useful validation messages live.
Multi-tenant / per-country credentials
The service provider typically binds a singleton configured from config/dhlparcel.php. If you ship for multiple legal entities or need different DHL accounts per market, don't fight the singleton: instantiate a fresh client with an explicit credential array and bind it per-request, or resolve a tenant-aware factory. Test this early — retrofitting it after launch is painful.
It's a 2020 package — pin and plan to fork
Last release August 2020, ~18 stars. Check composer.json for the supported Laravel/PHP range before assuming it installs on your stack; on Laravel 10/11 you will likely need "mvdnbrk/laravel-dhlparcel": "^1.0" with a config platform override, or a fork. It's a thin wrapper (a few hundred lines), so forking is genuinely cheap — treat "vendor it into app/Services/DhlParcel" as a first-class option rather than a last resort.
Units are the classic bug
DHL Parcel works in grams and millimetres (weight 1000 = 1 kg). Passing kilograms without thinking produces either rejected shipments or, worse, accepted ones billed at 1/1000th the real weight. Write the conversion once in your mapper and add a test asserting 1.5 kg → 1500.
Labels come back base64-encoded
Decode before writing to disk, or you'll ship PDFs that start with JVBERi0= instead of %PDF-. Check the response field name (pdf, label, data) against the actual response — this is the sort of detail that differs across DHL endpoints.
Print format matters operationally
A4 vs 4x6 (thermal) is a real decision that changes warehouse workflow. Pick it in config, not per-call, so the warehouse printer never gets a mismatched page. ZPL output, if supported, is raw printer language — never try to open it as a PDF to "check."
Errors arrive late and are vague
Address validation and product-code problems surface at label creation, not shipment creation. A POST /shipments success does not mean a printable label exists. Design your UI so the operator sees the failure at label time and can correct the address, rather than discovering it hours later in a failed job. Log the full error body plus any request ID header — DHL support will ask for it.
Credentials in the config cache
config:cache freezes credentials. If you use per-tenant keys, never read them from config() inside a cached deployment path — resolve them at runtime. Also keep sandbox and production hosts strictly separated via an env switch; a misconfigured APP_ENV that sends test shipments to production is billable and embarrassing.
Extension points worth knowing
ShippingProvider contract) is a one-file change.Debugging checklist when a call fails
userId/apiKey) and sandbox vs prod host.countryCode, or a postcode format DHL doesn't accept (NL postcodes need 1234AB, no space).How can I help you explore Laravel packages today?