A unified interface for multiple courier and shipping carrier services in Laravel.
This package provides a driver-based abstraction layer for courier integrations. Define your shipments once using strongly-typed DTOs — the driver handles the carrier-specific API calls and returns normalized results.
Each carrier ships as a separate Composer package. Install only what you need.
- PHP 8.1+
- Laravel 10, 11, 12, or 13
composer require laraditz/courierThe service provider is auto-discovered. Publish the config:
php artisan vendor:publish --tag=courier-configPublish and run the migrations to create the API/webhook log tables:
php artisan vendor:publish --tag=courier-migrations
php artisan migrate| Package | Carrier |
|---|---|
| laraditz/courier-sfexpress | SF Express |
| laraditz/courier-lalamove | Lalamove |
| laraditz/courier-jt-express | J&T Express (MY) |
config/courier.php:
return [
'default' => env('COURIER_DRIVER', 'sfexpress'),
'drivers' => [
'sfexpress' => [
'key' => env('SFEXPRESS_KEY'),
'secret' => env('SFEXPRESS_SECRET'),
'customer_code' => env('SFEXPRESS_CUSTOMER_CODE'),
'encoding_aes_key' => env('SFEXPRESS_AES_KEY'),
'pay_month_card' => env('SFEXPRESS_PAY_MONTH_CARD'),
'country' => env('SFEXPRESS_COUNTRY', 'MY'),
'scope_name' => env('SFEXPRESS_SCOPE', 'OSMY'),
'sandbox' => env('SFEXPRESS_SANDBOX', false),
],
],
];| Method | Parameters | Returns | Description |
|---|---|---|---|
createShipment |
ShipmentPayload $payload |
ShipmentResult |
Book a new shipment and get a waybill number |
getShipment |
string $reference |
ShipmentResult |
Look up a shipment by your own order reference |
track |
string $trackingNumber |
TrackingResult |
Get current status and full tracking history |
getRates |
RatePayload $payload |
RateCollection |
Fetch available service options and prices for a route |
cancelShipment |
string $waybillNumber, ?string $reference = null |
CancelResult |
Cancel an existing shipment |
getLabel |
string $waybillNumber, ?string $reference = null |
LabelResult |
Retrieve the shipping label (PDF bytes or ZPL) |
getAvailability |
AvailabilityPayload $payload |
ServiceCollection |
List services available between two locations |
Driver support: Not all drivers implement every method. Calling an unsupported method throws
Laraditz\Courier\Exceptions\UnsupportedOperationException. Check the driver's documentation for which methods are available.
$reference: your own order reference, if one was supplied (or generated by the driver) when the shipment was created — seeShipmentResult::$referencebelow. Some carriers (e.g. J&T Express) require it to cancel a shipment or fetch its label, since their APIs key off your reference rather than the waybill number. Pass it back in from wherever you persisted the originalShipmentResult.
| DTO | Key Properties |
|---|---|
ShipmentResult |
waybillNumber, status, estimatedDelivery, reference: ?string, meta() |
TrackingResult |
waybillNumber, status, estimatedDelivery, events[], meta() |
TrackingEvent |
timestamp, location, description, status |
RateCollection |
items[] → RateOption |
RateOption |
serviceCode, serviceName, price, currency, estimatedDays, meta() |
CancelResult |
success, message, meta() |
LabelResult |
waybillNumber, format, content, meta() |
ServiceCollection |
items[] → ServiceOption |
ServiceOption |
code, name, description, estimatedDays |
| DTO | Properties |
|---|---|
ShipmentPayload |
sender: Address, recipient: Address, parcel: Parcel, serviceCode: string, remarks: ?string, scheduledAt: ?Carbon, reference: ?string |
RatePayload |
origin: Location, destination: Location, parcel: Parcel, serviceCode: string |
AvailabilityPayload |
origin: Location, destination: Location |
Address |
name, phone, email, line1, line2, line3, city, state, postcode, country, lat, lng |
Location |
postcode, city, state, country, lat, lng |
Parcel |
weight, length, width, height, declaredValue, description, quantity |
use Laraditz\Courier\Facades\Courier;
use Laraditz\Courier\DTOs\Shared\Address;
use Laraditz\Courier\DTOs\Shared\Parcel;
use Laraditz\Courier\DTOs\Payloads\ShipmentPayload;
$result = Courier::createShipment(new ShipmentPayload(
sender: new Address(
name: 'Raditz Farhan',
phone: '+60123456789',
email: null,
line1: 'No 1 Jalan Test',
line2: null,
line3: null,
city: 'Kuala Lumpur',
state: 'Wilayah Persekutuan',
postcode: '50000',
country: 'MY',
),
recipient: new Address(/* ... */),
parcel: new Parcel(
weight: 1.5,
length: 20.0,
width: 15.0,
height: 10.0,
declaredValue: 100.0,
description: 'Goods',
quantity: 1,
),
serviceCode: 'M102',
));
$result->waybillNumber; // 'MYIU1234715622'
$result->status; // 'pending'
$result->reference; // your $reference (or a generated one) — persist this if the driver needs it later$result = Courier::getShipment('ORDER-001');
$result->waybillNumber; // 'MYIU1234715622'Not every driver supports order inquiry — check the driver's documentation.
$result = Courier::track('MYIU1234715622');
$result->waybillNumber; // 'MYIU1234715622'
$result->status; // 'in_transit'
$result->events; // TrackingEvent[]
foreach ($result->events as $event) {
$event->timestamp; // Carbon
$event->location; // 'Kuala Lumpur Hub'
$event->description; // 'Package picked up'
$event->status; // 'picked_up'
}use Laraditz\Courier\DTOs\Shared\Location;
use Laraditz\Courier\DTOs\Payloads\RatePayload;
$rates = Courier::getRates(new RatePayload(
origin: new Location('50000', 'Kuala Lumpur', 'Wilayah Persekutuan', 'MY'),
destination: new Location('10000', 'Georgetown', 'Pulau Pinang', 'MY'),
parcel: new Parcel(1.5, 20, 15, 10, 100, 'Goods', 1),
));
foreach ($rates->items as $option) {
$option->serviceCode; // 'STANDARD'
$option->serviceName; // 'Standard Delivery'
$option->price; // 12.50
$option->currency; // 'MYR'
$option->estimatedDays; // 3
}// Cancel
$result = Courier::cancelShipment('MYIU1234715622');
$result->success; // true
// Cancel — some drivers require the original reference too
$result = Courier::cancelShipment('MYIU1234715622', 'ORDER-001');
// Get label — content is raw bytes (PDF or ZPL depending on driver)
$result = Courier::getLabel('MYIU1234715622');
$result->format; // 'pdf'
$result->content; // raw bytes
// Get label — some drivers require the original reference too
$result = Courier::getLabel('MYIU1234715622', 'ORDER-001');
// Check service availability by route
$services = Courier::getAvailability(new AvailabilityPayload(
origin: new Location('50000', 'Kuala Lumpur', 'Wilayah Persekutuan', 'MY'),
destination: new Location('10000', 'Georgetown', 'Pulau Pinang', 'MY'),
));// Use a specific driver explicitly
Courier::driver('sfexpress')->track('MYIU1234715622');The package registers a POST /courier/webhook/{driver} route that drivers can use to receive carrier push notifications.
To enable webhook support in a driver, implement the HandlesWebhooks interface:
use Illuminate\Http\Request;
use Laraditz\Courier\Contracts\HandlesWebhooks;
class MyCarrierDriver implements CourierDriver, HandlesWebhooks
{
public function verifyWebhook(Request $request): bool
{
// Validate the carrier's signature/token
return $request->header('X-Carrier-Token') === config('courier.drivers.mycarrier.webhook_secret');
}
public function handleWebhook(Request $request): void
{
// Process the carrier-specific payload
}
}When a valid webhook is received, the package fires a WebhookReceived event:
use Laraditz\Courier\Events\WebhookReceived;
// In your EventServiceProvider
Event::listen(WebhookReceived::class, function (WebhookReceived $event) {
$event->driver; // 'mycarrier'
$event->payload; // raw request data array
});Drivers that do not implement HandlesWebhooks return 404. Requests that fail verifyWebhook return 401.
To associate an incoming webhook log with the shipment it relates to, a driver can also implement ExtractsWebhookReference:
use Illuminate\Http\Request;
use Laraditz\Courier\Contracts\ExtractsWebhookReference;
class MyCarrierDriver implements CourierDriver, HandlesWebhooks, ExtractsWebhookReference
{
public function extractWebhookReference(Request $request): array
{
return [
'reference' => $request->input('order_no'),
'waybillNumber' => $request->input('waybill_no'),
];
}
}Extraction is best-effort — if it throws, the failure is swallowed and logging/processing continues without a reference.
Every outbound API call made through CourierHttpClient and every inbound webhook request is recorded (see Installation for the migrations), giving you a full audit trail per shipment.
Configure logging in config/courier.php:
'logging' => [
'enabled' => env('COURIER_LOGGING_ENABLED', true),
// Days to keep logs for `courier:prune-logs`. Set to null to disable pruning.
'retention_days' => env('COURIER_LOGGING_RETENTION_DAYS', 90),
// Request/response keys whose values are redacted before being stored.
'redact' => [
'authorization',
'api_key',
'apikey',
'key',
'secret',
'token',
'password',
],
],Query the logs directly via their models:
use Laraditz\Courier\Models\CourierApiLog;
use Laraditz\Courier\Models\CourierWebhookLog;
CourierApiLog::forReference('ORDER-001')->get();
CourierApiLog::forDriver('sfexpress')->failed()->get();
CourierWebhookLog::forDriver('sfexpress')->processed()->get();
CourierWebhookLog::rejected()->get();| Model | Scopes | Notes |
|---|---|---|
CourierApiLog |
forReference, forDriver, successful, failed |
One row per outbound HTTP call (request/response, duration, status) |
CourierWebhookLog |
forReference, forDriver, processed, rejected, failed |
One row per inbound webhook (status is processed, rejected, or failed) |
A write failure while logging is caught and reported to the default log channel — it never breaks the underlying courier call or webhook request.
Prune logs older than courier.logging.retention_days (a no-op when it's null):
php artisan courier:prune-logsSchedule it to run periodically in your app's console kernel or routes/console.php:
Schedule::command('courier:prune-logs')->daily();Use Courier::fake() to mock courier calls in tests:
use Laraditz\Courier\Facades\Courier;
$fake = Courier::fake();
// Your code under test runs here...
$this->service->bookShipment($order);
$fake->assertShipmentCreated(1);
$fake->assertShipmentCreated(fn ($payload) => $payload->serviceCode === 'STANDARD');
$fake->assertTracked('SF1234567890');
$fake->assertCancelled('SF1234567890');
$fake->assertRatesFetched();
$fake->assertLabelFetched('SF1234567890');
$fake->assertNothingSent();
// getShipment() calls are recorded too — provide a preset response the same way as createShipment
$fake = Courier::fake(['getShipment' => new ShipmentResult('CUSTOM-002', 'pending', null)]);Provide preset responses:
use Laraditz\Courier\DTOs\Results\ShipmentResult;
$fake = Courier::fake([
'createShipment' => new ShipmentResult('CUSTOM-001', 'pending', null),
]);All drivers map their carrier-specific statuses to these values:
| Status | Meaning |
|---|---|
pending |
Shipment created, not yet picked up |
picked_up |
Collected by courier |
in_transit |
Moving through the network |
out_for_delivery |
On the delivery vehicle |
delivered |
Successfully delivered |
failed_delivery |
Delivery attempt failed |
returned |
Returned to sender |
cancelled |
Shipment cancelled |
unknown |
Status not recognized |
Implement Laraditz\Courier\Contracts\CourierDriver and register it:
// In your ServiceProvider
$this->app->make('courier')->extend('mycarrier', function ($app, $config) {
return new MyCarrierDriver($config);
});To receive push notifications from the carrier, also implement Laraditz\Courier\Contracts\HandlesWebhooks. See the Webhooks section for details.
MIT