Skip to content
laraditzPublic

About

A unified interface for multiple courier and shipping carrier services in Laravel.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

116 Commits

Folders and files

Repository files navigation

Laravel Courier

Latest Version on Packagist Total Downloads License

A unified interface for multiple courier and shipping carrier services in Laravel.

Overview

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.

Requirements

  • PHP 8.1+
  • Laravel 10, 11, 12, or 13

Installation

composer require laraditz/courier

The service provider is auto-discovered. Publish the config:

php artisan vendor:publish --tag=courier-config

Publish and run the migrations to create the API/webhook log tables:

php artisan vendor:publish --tag=courier-migrations
php artisan migrate

Available Drivers

Package Carrier Driver name
laraditz/courier-sfexpress SF Express sfexpress
laraditz/courier-lalamove Lalamove lalamove
laraditz/courier-jt-express J&T Express (MY) jtexpress
laraditz/courier-spx-express SPX Express (MY) spxexpress

Configuration

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),
        ],
    ],

    'webhook' => [
        // Inbound webhook requests per minute, per driver per IP.
        // null disables throttling. See Webhooks → Webhook rate limiting.
        'rate_limit' => env('COURIER_WEBHOOK_RATE_LIMIT', 60),
    ],
];

Available Methods

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
getDeliveryModes — DeliveryMode[] Which delivery mode(s) this driver supports — OnDemand, Scheduled, or both

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 — see ShipmentResult::$reference below. 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 original ShipmentResult.

Result DTOs

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
QuotationResult quotationId, price, currency, expiresAt, meta()
DriverLocationResult driverId, lat, lng, updatedAt, meta()
PodResult status, imageUrl: ?string, deliveredAt: ?Carbon

Payload DTOs

DTO Properties
ShipmentPayload sender: Address, recipient: Address, parcel: Parcel, serviceCode: string, remarks: ?string, scheduledAt: ?Carbon, reference: ?string, meta: array
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

ShipmentPayload::$meta: driver-specific options that have no place in the normalized payload — for example ['isPODEnabled' => true] for a carrier that supports proof of delivery. A driver ignores keys it does not recognise, so populating meta never changes the request body of a driver that does not read it. Defaults to [].


Usage

Create a Shipment

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

Look Up a Shipment by Reference

$result = Courier::getShipment('ORDER-001');

$result->waybillNumber; // 'MYIU1234715622'

Not every driver supports order inquiry — check the driver's documentation.

Track a Shipment

$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'
}

Get Rates

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
}

Other Operations

// 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'),
));

Switching Drivers

// Use a specific driver explicitly
Courier::driver('sfexpress')->track('MYIU1234715622');

Webhooks

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 by default — a driver can shape that response itself, see Shaping the webhook response below.

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.

Shaping the webhook response

By default the endpoint returns an empty 200 on success and a 401 on failed verification. Some carriers parse the response body and treat anything else as a failed push — J&T Express, for example, checks the body for code == "1" and retries until it sees it.

A driver that needs to control what goes back on the wire implements ProvidesWebhookResponse:

use Illuminate\Http\Request;
use Laraditz\Courier\Contracts\ProvidesWebhookResponse;
use Symfony\Component\HttpFoundation\Response;

class MyCarrierDriver implements CourierDriver, HandlesWebhooks, ProvidesWebhookResponse
{
    public function webhookAcceptedResponse(Request $request): Response
    {
        return response()->json([
            'code' => '1',
            'msg' => 'success',
            'data' => 'SUCCESS',
            'requestId' => $request->input('requestId'),
        ]);
    }

    public function webhookRejectedResponse(Request $request): Response
    {
        return response()->json([
            'code' => '145003030',
            'msg' => 'headers signature verification failed',
        ], 401);
    }
}

Both methods return Symfony\Component\HttpFoundation\Response rather than Illuminate\Http\Response, so response()->json() is accepted — JsonResponse is a sibling of Illuminate\Http\Response, not a subclass.

Notes:

  • The interface is optional. A driver that does not implement it keeps the empty 200 and the plain 401, unchanged.
  • A rejected request is still logged before webhookRejectedResponse() is called, so shaping the response never costs you the audit row.
  • Neither method is called when handleWebhook() throws — that still surfaces as a 500.
  • Exceptions thrown inside either method are not swallowed. A failure to build the ack is a real failure, and hiding it behind an empty 200 would put you straight back into the retry loop the contract exists to prevent.
Referencing the log row

Carriers that require an identifier in the acknowledgement rarely send one you can echo back. The webhook log row's id is available on the request, set before either contract method is called:

public function webhookAcceptedResponse(Request $request): Response
{
    return response()->json([
        'code' => '1',
        'requestId' => (string) $request->attributes->get('courier.webhook_log_id'),
    ]);
}

It is an ?int, and it is null when the log write failed — that failure is swallowed so it never breaks a webhook, which means a driver must treat null as "no row to reference" and fall back to something of its own. It is set on the rejected path too, so a rejection can carry a reference support can look up.

Look the row up with CourierWebhookLog::find($id).

Webhook rate limiting

The webhook route is throttled per driver, per IP — one carrier pushing hard cannot exhaust another carrier's allowance from the same address.

// config/courier.php
'webhook' => [
    'rate_limit' => env('COURIER_WEBHOOK_RATE_LIMIT', 60),
],

The default is 60 requests per minute. To give a single carrier more headroom without touching the others, set a per-driver override:

'drivers' => [
    'jtexpress' => [
        // ...
        'webhook' => ['rate_limit' => 300],
    ],
],

Resolution order is per-driver, then global, then the built-in default of 60. Setting either to null disables throttling for that scope:

'webhook' => ['rate_limit' => null],   // no throttling at all

A value that is not a usable ceiling — 0, a negative number, a non-numeric string — is treated as unconfigured and falls back to 60, so a typo cannot silently disable throttling or lock the endpoint out.

Requests over the limit get Laravel's standard 429. The rate limiter runs as middleware, before the controller, so ProvidesWebhookResponse cannot shape that response.

On-Demand Driver Capabilities

Every driver declares which delivery mode(s) it supports via getDeliveryModes(): DeliveryMode[]:

use Laraditz\Courier\Enums\DeliveryMode;

Courier::driver('lalamove')->getDeliveryModes(); // [DeliveryMode::OnDemand]
Courier::driver('jtexpress')->getDeliveryModes(); // [DeliveryMode::Scheduled]
Courier::driver('spxexpress')->getDeliveryModes(); // [DeliveryMode::Scheduled]

DeliveryMode::OnDemand means instant/live-driver dispatch (e.g. Lalamove); DeliveryMode::Scheduled means next-day/express delivery with a fixed pickup and drop-off. A driver that supports both returns both cases in the array.

On-demand drivers can optionally implement these capability interfaces, checked the same way as HandlesWebhooks — via instanceof:

Interface Method Returns
LooksUpQuotations getQuotation(string $quotationId) QuotationResult
ManagesAssignedDriver removeDriver(string $orderId, string $driverId) void
TracksDriverLocation getDriverLocation(string $orderId, string $driverId) DriverLocationResult
SupportsOrderEditing editOrder(string $orderId, Address[] $stops) ShipmentResult
use Laraditz\Courier\Contracts\TracksDriverLocation;

$driver = Courier::driver('lalamove');

if ($driver instanceof TracksDriverLocation) {
    $location = $driver->getDriverLocation($orderId, $driverId);
    $location->lat;
    $location->lng;
}

None of these are required — a driver with no live-tracking or quotation-lookup endpoint simply doesn't implement the corresponding interface, and calling code checks instanceof before relying on it, exactly like HandlesWebhooks.

Logging

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',
        'appkey',
        'appsecret',
        'signature',
        'digest',
        'apiaccount',
    ],
],

Matching is on exact (case-insensitive) key names, so add the specific keys your driver sends — apikey does not cover appKey. Two limits worth knowing:

  • The url column is stored verbatim and is never redacted. Never put a credential in a URL string; pass it as a query array or header instead.
  • Redaction does not descend into JSON held inside a string value — a ['bizContent' => '{"password":"..."}'] body keeps its nested contents as-is.

Making requests from a driver

Drivers make their HTTP calls through CourierHttpClient — that wrapper is what produces the courier_api_logs rows. forLog() supplies the log context and is the only way to reach the verb methods; calling get/post/put/patch/delete without it throws a LogicException:

use Laraditz\Courier\Http\CourierHttpClient;

$response = (new CourierHttpClient())
    ->forLog(driver: 'acme', action: 'create_order', reference: 'ORDER-001', waybillNumber: 'ACME123')
    ->post('https://api.acme.test/v1/orders', ['weight' => 1.5], ['X-Api-Key' => $key]);

Verb methods take (string $url, array $data = [], array $headers = []) and return the underlying Illuminate\Http\Client\Response untouched. Two optional modifiers:

->asForm()      // send the body form-encoded instead of as JSON
->timeout(15)   // override the request timeout in seconds (Laravel's default of 30 applies otherwise)

forLog() mutates the instance and returns it — it does not clone. Once called, the instance stays configured, so a later request that forgets forLog() will not throw: it logs against the previous call's action, reference and waybill_number. Call forLog() on every request, or construct a fresh CourierHttpClient per request. Do not hold a configured instance as state.

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-logs

Schedule it to run periodically in your app's console kernel or routes/console.php:

Schedule::command('courier:prune-logs')->daily();

Testing

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),
]);

Normalized Status Vocabulary

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

Building a Custom Driver

Implement Laraditz\Courier\Contracts\CourierDriver and register it:

// In your ServiceProvider
$this->app->make('courier')->extend('mycarrier', function ($app, $config) {
    return new MyCarrierDriver($config);
});

getDeliveryModes(): DeliveryMode[] is part of CourierDriver and must be implemented — return [DeliveryMode::OnDemand], [DeliveryMode::Scheduled], or both.

To receive push notifications from the carrier, also implement Laraditz\Courier\Contracts\HandlesWebhooks. If the carrier parses your response body — many do — add ProvidesWebhookResponse so the driver controls what goes back on the wire. See the Webhooks section for details.

For on-demand carriers (live driver dispatch, quotations, etc.), optionally implement LooksUpQuotations, ManagesAssignedDriver, TracksDriverLocation, and/or SupportsOrderEditing. See On-Demand Driver Capabilities for details.

License

MIT

About

A unified interface for multiple courier and shipping carrier services in Laravel.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages