amdadulhaq / bd-courier-laravel
Create shipments and track parcels from Laravel via popular Bangladeshi courier services (Pathao, Steadfast, RedX, eCourier, Paperfly, Sundarban, SA Paribahan, Karatoa), behind one driver-based API.
Fund package maintenance!
Requires
- php: ^8.2|^8.3|^8.4|^8.5
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- driftingly/rector-laravel: ^2.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
Create shipments and track parcels from Laravel via popular Bangladeshi courier services (Pathao, Steadfast, RedX, eCourier, Paperfly, Sundarban, SA Paribahan, Karatoa), all behind one driver-based API.
Contents
- Requirements
- Installation
- Configuration
- Which driver do I need?
- Booking a shipment
- Handling webhooks
- API reference
- Adding your own courier
- Testing
- Troubleshooting
- Running the package's own test suite
- License
Requirements
- PHP 8.2, 8.3, 8.4, or 8.5
- Laravel 11, 12, or 13
Installation
composer require amdadulhaq/bd-courier-laravel
The service provider and Courier facade are auto-discovered. Publish the config file:
php artisan vendor:publish --tag=courier-config
Configuration
Set the default driver and credentials in your .env:
COURIER_DRIVER=steadfast STEADFAST_API_KEY=your-api-key STEADFAST_SECRET_KEY=your-secret-key
steadfast has the simplest signup of the bunch, so it's the default — start there if you don't have merchant credentials for anything else yet.
Or, for Pathao:
COURIER_DRIVER=pathao PATHAO_CLIENT_ID=your-client-id PATHAO_CLIENT_SECRET=your-client-secret PATHAO_USERNAME=your-username PATHAO_PASSWORD=your-password PATHAO_STORE_ID=your-store-id
Or, for RedX:
COURIER_DRIVER=redx REDX_API_TOKEN=your-api-token REDX_PICKUP_STORE_ID=your-pickup-store-id
Or, for eCourier:
COURIER_DRIVER=ecourier ECOURIER_USER_ID=your-user-id ECOURIER_API_KEY=your-api-key
Or, for Paperfly:
COURIER_DRIVER=paperfly PAPERFLY_MERCHANT_ID=your-merchant-id PAPERFLY_USERNAME=your-username PAPERFLY_PASSWORD=your-password
Or, for Sundarban:
COURIER_DRIVER=sundarban SUNDARBAN_API_KEY=your-api-key SUNDARBAN_BOOKING_USER_ID=your-booking-user-id
Or, for SA Paribahan:
COURIER_DRIVER=sa_paribahan SA_PARIBAHAN_API_KEY=your-api-key SA_PARIBAHAN_BOOKING_BRANCH=your-booking-branch
Or, for Karatoa:
COURIER_DRIVER=karatoa KARATOA_API_TOKEN=your-api-token KARATOA_MERCHANT_ID=your-merchant-id
See config/courier.php for every driver's options. COURIER_DRIVER defaults to steadfast.
Which driver do I need?
| Driver | Cancel shipment? | Price calculator? | Server-to-server webhook? | Notes |
|---|---|---|---|---|
pathao |
No | Yes | No (poll/track instead) | Requires an issued OAuth token; auto-renewed and cached. |
steadfast |
No | No | No (poll/track instead) | Simplest signup, popular with small merchants. |
redx |
Yes | Yes | No (poll/track instead) | |
ecourier |
No | No | No (poll/track instead) | |
paperfly |
No | No | No (poll/track instead) | |
sundarban |
No | No | No (poll/track instead) | |
sa_paribahan |
No | No | No (poll/track instead) | |
karatoa |
Yes | No | No (poll/track instead) |
Every driver throws AmdadulHaq\BdCourier\Exceptions\CourierException for operations it doesn't support, so calling code can rely on the same contract regardless of which courier is active.
Booking a shipment
use AmdadulHaq\BdCourier\Facades\Courier; use AmdadulHaq\BdCourier\DataTransferObjects\ShipmentRequest; $response = Courier::createShipment(new ShipmentRequest( invoiceNumber: 'INV-1001', recipientName: 'John Doe', recipientPhone: '01700000000', recipientAddress: 'House 1, Road 2, Gulshan', recipientCity: 'Dhaka', recipientZone: 'Gulshan', codAmount: 1200.00, itemWeight: 0.5, itemDescription: 'T-Shirt', )); $response->consignmentId; // the courier's own tracking/consignment ID — save this $response->status; // AmdadulHaq\BdCourier\Enums\ShipmentStatus
Track a shipment any time without mutating anything:
$status = Courier::track($response->consignmentId); if ($status->status->isSuccessful()) { // mark the order delivered }
Cancel a shipment (not every courier/driver supports this):
Courier::cancel($response->consignmentId);
Estimate the delivery fee before booking (not every courier/driver supports this):
$fee = Courier::calculatePrice($request);
Use a specific driver, or a driver other than the default, for one call — same as Storage::disk():
Courier::driver('pathao')->createShipment($request);
Handling webhooks
Some couriers notify your server directly with status updates instead of (or in addition to) you polling track(). Point that URL at a route in your app and hand the request straight to the driver:
use AmdadulHaq\BdCourier\Facades\Courier; Route::post('/webhooks/redx', function (Request $request) { $result = Courier::driver('redx')->handleWebhook($request->all()); if ($result->status->isSuccessful()) { // mark the order delivered } return response()->noContent(); })->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
None of the couriers supported out of the box sign their webhook payload, so every driver treats the incoming payload as a hint at most: it extracts the consignment/tracking ID and calls the courier's own track() endpoint to get the real, authoritative status rather than trusting the payload directly. Drivers with no webhook mechanism at all throw AmdadulHaq\BdCourier\Exceptions\CourierException.
API reference
ShipmentRequest
Everything a driver might need to book a shipment — pass what applies, ignore the rest (e.g. drivers without a price calculator ignore fields they don't read).
| Property | Type | Notes |
|---|---|---|
invoiceNumber |
string |
Required. Your own unique reference — becomes merchant_order_id (Pathao), invoice (Steadfast), merchant_invoice_id (RedX), etc. |
recipientName |
string |
Required. |
recipientPhone |
string |
Required. |
recipientAddress |
string |
Required. Full delivery address. |
recipientCity, recipientZone, recipientArea |
string |
District/thana/area — required by drivers that route by zone (Pathao, RedX). Defaults to '' for drivers that parse the full address instead. |
codAmount |
float |
Cash-on-delivery amount to collect. Defaults to 0.0 for prepaid parcels. |
itemWeight |
float |
In kilograms. Defaults to 0.5. |
itemDescription |
string |
Defaults to ''. |
itemQuantity |
int |
Defaults to 1. |
specialInstruction |
?string |
Delivery notes passed through to the courier where supported. |
metadata |
array<string, mixed> |
Driver-specific extras not covered above. |
ShipmentResponse
What every driver method returns, so calling code never branches on which courier answered.
| Property | Type | Meaning |
|---|---|---|
status |
ShipmentStatus |
Normalized status — see below. |
invoiceNumber |
string |
Echoes back your invoice/reference. |
consignmentId |
?string |
The courier's own tracking/consignment ID — save this, you'll need it for track()/cancel(). |
trackingUrl |
?string |
A public tracking link/code, when the courier returns one. |
deliveryFee |
?float |
The confirmed or quoted delivery charge, when the courier reports one. |
message |
?string |
Human-readable status text from the courier. |
raw |
array<string, mixed> |
The untouched courier response — keep this for auditing/debugging, don't build logic on it directly. |
ShipmentStatus
| Case | Meaning |
|---|---|
Pending |
Booked, not yet picked up. |
PickupRequested |
Pickup scheduled with the rider/hub. |
PickedUp |
Rider has collected the parcel. |
InTransit |
Parcel is moving between hubs / out for delivery. |
Delivered |
Delivered and (if COD) collected — $status->isSuccessful() is true. |
PartialDelivered |
Only part of the order was accepted/delivered. |
Returned |
Parcel came back to the merchant. |
Cancelled |
Shipment was cancelled before/after booking. |
Failed |
Delivery attempt failed, or the courier reported an unrecognized status. |
$status->isFinal() is true for every case except Pending, PickupRequested, PickedUp, and InTransit.
Adding your own courier
Register a custom driver:
use AmdadulHaq\BdCourier\Facades\Courier; Courier::extend('my-courier', function ($app) { return new MyCourierDriver(/* ... */); });
Any driver just needs to implement AmdadulHaq\BdCourier\Contracts\CourierDriver:
interface CourierDriver { public function createShipment(ShipmentRequest $request): ShipmentResponse; public function track(string $consignmentId): ShipmentResponse; public function cancel(string $consignmentId): ShipmentResponse; public function calculatePrice(ShipmentRequest $request): float; public function handleWebhook(array $payload): ShipmentResponse; }
Testing
Use CourierManager::fake() to swap the real courier with an in-memory fake and assert on what would have been booked, without dispatching anything or hitting the network:
use AmdadulHaq\BdCourier\CourierManager; use AmdadulHaq\BdCourier\Facades\Courier; $fake = CourierManager::fake(); // ... code under test that calls Courier::createShipment() ... $fake->assertShipmentCreated('INV-1001'); $fake->assertNothingCreated();
Troubleshooting
Pathao: "Failed to issue an access token."
Double-check PATHAO_USERNAME/PATHAO_PASSWORD (your Pathao Merchant Panel credentials, not a courier account PIN) and that PATHAO_BASE_URL matches your environment — the sandbox and production base URLs are different hosts, not just different credentials.
Steadfast / RedX / others: createShipment() throws a gateway error with no useful message
Most of these couriers return a generic HTTP 4xx with the real reason buried in the JSON body. Catch AmdadulHaq\BdCourier\Exceptions\CourierException and inspect $exception->context — it holds the courier's raw decoded response.
A webhook route never fires handleWebhook()
Make sure the route excludes Laravel's CSRF middleware (none of these couriers can obtain a CSRF token) and that you're passing the entire raw POST payload — $request->all() — since each driver looks for a specific ID field (consignment_id, tracking_id, tracking_number, etc.) that varies by courier.
"Nothing happens" in local development
COURIER_DRIVER defaults to steadfast. If you're expecting a different courier, confirm .env actually sets COURIER_DRIVER and that you ran php artisan config:clear after changing it (cached config wins over .env).
Running the package's own test suite
composer install composer test # Pest composer analyse # Larastan composer lint:check # Pint
License
MIT. See LICENSE.md.