bernhardk/laravel-dpd
A Laravel integration for DPD parcel shipping that helps you create shipments, generate labels, print documents, and track packages through DPD APIs. Includes configurable credentials, a clean service wrapper, and support for common shipping workflows.
Install and publish config
composer require bernhardk/laravel-dpd
php artisan vendor:publish --provider="BernhardK\LaravelDpd\DpdServiceProvider"
Add credentials to .env (names vary — check config/dpd.php after publishing):
DPD_ENDPOINT=sandbox
DPD_DELIS_ID=your-customer-number
DPD_USERNAME=...
DPD_PASSWORD=...
DPD_SANDBOX=true
Where to look first
config/dpd.php — the only real documentation surface. Note every key: endpoint/mode, credentials, default label format, default weight unit, and any depot/product defaults.src/DpdServiceProvider.php — shows what is bound in the container and whether a facade alias is registered.vendor/bernhardk/laravel-dpd/README.md — with ~14 stars, treat the source as the reference and the README as a hint.First use case: one sandbox shipment, end to end
Point the config at the sandbox endpoint and run a single test shipment from php artisan tinker. You want to prove three things separately before wiring it into a real flow:
delisId fails here, not at label time).Do not skip step 3 — many integrations create the shipment fine and then fail on label retrieval because the label service is a separate call with its own payload.
$shipment = Dpd::createShipment([
'reference' => 'TEST-0001',
'receiver' => [...],
'parcels' => [['weight' => 2000]], // check unit in config (g vs kg!)
]);
Storage::disk('local')->put("labels/{$shipment->id}.pdf", $shipment->label());
Once that loop is green against sandbox, you have everything needed to productise.
Wrap the facade — don't scatter it through controllers
The package surface is DPD-specific and likely to change (small package, frequent-ish releases). Put one adapter between your domain and the package:
namespace App\Services\Shipping;
class DpdGateway
{
public function __construct(private array $config) {}
public function ship(Order $order): ShipmentResult
{
// translate Order -> DPD payload, call facade, map response -> your DTO
}
}
Bind it to an interface in a service provider so you can swap couriers later and fake it in tests. This is the single highest-value decision when adopting a low-star courier package.
Order → shipment as a queued job, not a controller call
Shipping API calls are slow (SOAP, 5–30s) and can time out. Dispatch a job:
class CreateDpdShipment implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300];
public $timeout = 60;
public function handle(DpdGateway $dpd)
{
if ($this->order->dpd_shipment_id) {
return; // idempotency guard — see Gotchas
}
$result = $dpd->ship($this->order);
$this->order->update([
'dpd_shipment_id' => $result->id,
'dpd_tracking' => $result->trackingNumber,
]);
}
}
Trigger it from an OrderPaid event listener so the payment path stays fast and a DPD outage doesn't block checkout.
Store labels on a disk, keep only the path in the DB
$pdf = $dpd->label($order->dpd_shipment_id);
Storage::disk('s3')->put($path = "labels/{$order->id}.pdf", $pdf);
$order->update(['label_path' => $path]);
Labels are 100–500 KB binary blobs; the DB will thank you. Generate on demand and cache — DPD label URLs often expire, so store the bytes, not a remote URL.
Runtime config switching for sandbox vs production
If you support a staging environment, switch the endpoint per-request rather than maintaining two configs:
config(['dpd.endpoint' => $isTest ? 'sandbox' : 'production']);
Do this in a dedicated service provider or a small DpdEnvironment helper, never inline in a controller. Remember config() changes are per-request only.
Tracking as a scheduled poll
Unless the package supports webhooks (unlikely for classic DPD webservices), poll:
Schedule::command('dpd:sync-tracking')->hourly()->withoutOverlapping();
Only poll shipments in a terminal-pending state (shipped, not delivered), and back off the frequency as parcels age. DPD tracking endpoints are rate-limited and slow; a naive "poll everything" command will get you throttled.
Test with a fake, contract-test against sandbox
Unit tests should never hit DPD. Bind a fake DpdGateway in the container. Keep one @group integration test that runs against sandbox on CI nightly — it's the only thing that catches DPD silently changing a response shape.
Units are the #1 silent bug. DPD payloads commonly expect weight in grams and dimensions in cm; the package may or may not normalise for you. Confirm in the source what weight means and assert it in a unit test. A 1.5 kg parcel sent as 1.5 instead of 1500 may still be accepted and billed wrong.
Create is not idempotent — guard hard. A retried job, a double-clicked admin button, or a tries = 3 job that times out after DPD accepted the shipment all cost real money and produce duplicate labels. Persist the shipment identifier immediately after success, use a unique DB constraint on the order reference, and check it at the top of the job. Never blindly retry a create call on timeout — reconcile against DPD first.
Config caching will bite you. php artisan config:cache after any .env change; a stale cached config with sandbox credentials in production is a classic deployment failure. Add a boot-time assertion that the endpoint and credential set match the environment.
Errors are not exceptions by default. DPD returns fault codes inside a successful HTTP/SOAP response. Look for how the package surfaces them — often an array of faultCode/faultString. Wrap them in a DpdException with the code preserved, and map the few codes you can actually act on (unknown address, invalid depot, label not ready) to typed exceptions. Everything else is "page someone".
Label readiness is asynchronous. If label retrieval fails right after creation, it may not be generated yet rather than genuinely broken — retry with a short backoff before alerting. Distinguish "not ready" from "not found".
Printer format matters more than you think. A6 vs A4 label layout and 203 vs 300 DPI thermal settings are payload options. Wrong format = labels that scan at the depot counter but not in the warehouse. Make the format an explicit config value, not a package default.
Customs data for non-EU destinations. CH, UK, NO shipments fail without commodity codes, declared values, and invoice attachments. Validate this in your own DTO layer before calling DPD so you get a useful error instead of an opaque fault code.
Timeouts and transport layer. Default Guzzle/SOAP timeouts are often too short for label generation. Set an explicit timeout (60s+) on the client if the package exposes it; if not, this is your first patch or a fork-worthy issue.
Logging is your only real debugging tool. Log the outgoing payload (redact credentials) and the raw response at debug level in a dedicated channel. With a SOAP backend, lastRequest/lastResponse is worth capturing. Without this you'll be guessing at field names during production incidents.
Extension points are thin. Expect to extend by binding your own implementation over the package's container binding, or by decorating the facade. If the client class isn't final and uses constructor injection, subclassing it and rebinding in register() is the cleanest hook.
Bus factor. At 14 stars and zero dependents, nobody else is filing these bugs. Pin the version in composer.json ("~1.0", not "^1.0"), read the diff of every upgrade, and budget a day to vendor a fork if the maintainer goes quiet. Keep your DpdGateway adapter thin so that day is cheap.
Last release date is a signal, not a guarantee. A 2026 release date means it's maintained right now — good. Verify against your Laravel version by checking illuminate/support constraints in composer.json before committing to it in a long-lived project.
How can I help you explore Laravel packages today?