Looking to hire Laravel developers? Try LaraJobs

steadfast-laravel-package maintained by steadfast-courier

Author
Last update
2026/11/10 17:29 (2.0.0)
License
Downloads
0

Comments
comments powered by Disqus

Book parcels, track deliveries, handle returns and pickups, and reconcile payments from your Laravel application. Every endpoint in the official SteadFast API documentation is covered, and every method returns the API's JSON decoded into a plain PHP array.

Works the same in an API-only backend, a queue worker, or a Blade application.

Contents

Requirements

Supported
PHP 8.2 – 8.5
Laravel 12.x, 13.x

Laravel 11 and earlier are not supported. Laravel 11 reached end of security support on 12 March 2026, and Composer's block-insecure audit default refuses to install any 11.x release. Projects still on Laravel 11 or older should stay on ^1.1 of this package.

Installation

composer require steadfast-courier/steadfast-laravel-package

Publish the config file:

php artisan vendor:publish --tag="steadfast-config"

Configuration

Add your credentials to .env. You can find them in your Steadfast merchant dashboard, or request them from Steadfast support.

STEADFAST_API_KEY="your-api-key"
STEADFAST_SECRET_KEY="your-secret-key"

Everything else is optional. The defaults are shown here, so you only need to add the lines you actually want to change:

STEADFAST_BASE_URL="https://portal.packzy.com/api/v1"

# Webhooks, see the Webhooks section below
STEADFAST_BEARER_TOKEN=

# Network behaviour
STEADFAST_TIMEOUT=30
STEADFAST_CONNECT_TIMEOUT=10
STEADFAST_RETRY_TIMES=2
STEADFAST_RETRY_SLEEP=200

The published config/steadfast.php documents each option inline. A trailing slash on STEADFAST_BASE_URL is fine, and leaving it unset raises a clear InvalidRequestException rather than a confusing HTTP client error.

Quick start

use SteadFast\SteadFastCourierLaravelPackage\Facades\SteadfastCourier;

// Book a parcel
$response = SteadfastCourier::placeOrder([
    'invoice' => 'INV-1001',
    'recipient_name' => 'Badrul Hasan Sajib',
    'recipient_phone' => '01309055966',
    'recipient_address' => 'Dhanmondi, Zigatola, Dhaka',
    'cod_amount' => 3010,
]);

$consignmentId = $response['consignment']['consignment_id'];

// Check on it later
$status = SteadfastCourier::checkDeliveryStatusByConsignmentId($consignmentId);

echo $status['delivery_status']; // "in_review"

If you prefer the registered alias, \SteadFastCourier::placeOrder(...) works identically.

Endpoint coverage

Method Endpoint Package method Description
POST /create_order placeOrder() Book a single parcel.
POST /create_order/bulk-order bulkCreateOrders() Book up to 500 parcels in one call.
GET /status_by_cid/{id} checkDeliveryStatusByConsignmentId() Current delivery status, by consignment ID.
GET /status_by_invoice/{invoice} 1 checkDeliveryStatusByInvoiceId() Current delivery status, by your own invoice number.
GET /status_by_trackingcode/{code} 1 checkDeliveryStatusByTrackingCode() Current delivery status, by tracking code.
GET /trackings_by_invoice/{invoice} getTrackingsByInvoice() Full event history for a consignment, not just its current status.
POST /create_return_request createReturnRequest() Ask for a parcel to be returned to you.
GET /get_return_requests getReturnRequests() List all of your return requests.
GET /get_return_request/{id} getReturnRequest() Read one return request.
POST /create_pickup_request createPickupRequest() Ask Steadfast to collect parcels from your address.
GET /get_balance getCurrentBalance() Your current merchant account balance.
GET /payments getPayments() List the payments Steadfast has made to you.
GET /payments/{payment_id} getPayment() Read one payment.
GET /police_stations getPoliceStations() Districts and their police stations, for pickup requests.
GET /fraud_check/{phone} fraudCheck() A recipient's delivery and cancellation history.

1 Not listed in the current Postman collection, but still served by the API and supported here since 1.x.

Each example below assumes this import:

use SteadFast\SteadFastCourierLaravelPackage\Facades\SteadfastCourier;

Orders

Place an order

$response = SteadfastCourier::placeOrder([
    // Required
    'invoice' => 'INV-1001',                         // your own reference, must be unique
    'recipient_name' => 'Badrul Hasan Sajib',
    'recipient_phone' => '01309055966',
    'recipient_address' => 'Dhanmondi, Zigatola, Dhaka',
    'cod_amount' => 3010,                            // 0 when nothing is collected

    // Optional
    'recipient_email' => 'badrul.sajib@gmail.com',
    'alternative_phone' => '01722743076',
    'item_description' => 'iPhone 16 Pro Max 512GB',
    'note' => 'Call before delivery.',
]);

Unrecognised keys are passed through untouched, so a field Steadfast adds later works without waiting for a package release.

{
    "status": 200,
    "message": "Consignment has been created successfully.",
    "consignment": {
        "consignment_id": 131822154,
        "invoice": "INV-1001",
        "tracking_code": "7DB724A65D4",
        "recipient_name": "Badrul Hasan Sajib",
        "recipient_phone": "01309055966",
        "recipient_address": "Dhanmondi, Zigatola, Dhaka",
        "cod_amount": 3010,
        "status": "in_review",
        "note": "Call before delivery.",
        "created_at": "2026-03-21T07:05:31.000000Z",
        "updated_at": "2026-03-21T07:05:31.000000Z"
    }
}

Create orders in bulk

Up to 500 orders per call.

$response = SteadfastCourier::bulkCreateOrders([
    [
        'invoice' => 'INV-1001',
        'recipient_name' => 'Badrul Hasan Sajib',
        'recipient_phone' => '01309055966',
        'recipient_address' => 'Dhanmondi, Zigatola, Dhaka',
        'cod_amount' => 3010,
    ],
    [
        'invoice' => 'INV-1002',
        'recipient_name' => 'Jane Smith',
        'recipient_phone' => '01711111111',
        'recipient_address' => 'House 44, Road 2/A, Dhanmondi, Dhaka 1209',
        'cod_amount' => 1500,
    ],
]);

foreach ($response['data'] as $consignment) {
    if ($consignment['status'] === 'error') {
        // This one order was rejected; the rest may have succeeded.
        continue;
    }

    // $consignment['consignment_id'], $consignment['tracking_code']
}
{
    "status": 200,
    "message": "We have a response for you.",
    "data": [
        {
            "invoice": "INV-1001",
            "recipient_name": "Badrul Hasan Sajib",
            "recipient_phone": "01309055966",
            "recipient_address": "Dhanmondi, Zigatola, Dhaka",
            "cod_amount": "3010",
            "consignment_id": 11543968,
            "tracking_code": "B025A038",
            "status": "success"
        }
    ]
}

A bulk call succeeds as a whole even when individual orders fail, so check each entry's status rather than assuming the batch went through.

These mistakes are caught before any request is sent, and raise InvalidRequestException:

Call Problem
bulkCreateOrders([]) nothing to send
bulkCreateOrders(['invoice' => '...']) a single order, where a list of orders is expected
more than 500 orders over the API limit, send them in chunks

Chunking is straightforward:

use SteadFast\SteadFastCourierLaravelPackage\SteadfastCourier as Client;

foreach (array_chunk($orders, Client::MAX_BULK_ORDERS) as $chunk) {
    SteadfastCourier::bulkCreateOrders($chunk);
}

Delivery status and tracking

Check a delivery status

Three interchangeable lookups, all returning the same shape:

SteadfastCourier::checkDeliveryStatusByConsignmentId(131822154);
SteadfastCourier::checkDeliveryStatusByInvoiceId('INV-1001');
SteadfastCourier::checkDeliveryStatusByTrackingCode('7DB724A65D4');
{
    "status": 200,
    "delivery_status": "partial_delivered"
}

Work with statuses using the enum

Comparing raw strings is easy to get wrong: a typo becomes a branch that silently never runs. Use the DeliveryStatus enum instead.

use SteadFast\SteadFastCourierLaravelPackage\Enums\DeliveryStatus;

$status = DeliveryStatus::fromResponse(
    SteadfastCourier::checkDeliveryStatusByConsignmentId($consignmentId)
);

if ($status?->isFinal()) {
    // Settled and the balance is updated. Stop polling.
}

if ($status?->isAwaitingApproval()) {
    // Outcome recorded, waiting on a Steadfast admin. The balance has not moved.
}

if ($status?->needsSupport()) {
    // Steadfast cannot classify this parcel. A human needs to look at it.
}

echo $status?->label(); // "Partially delivered and balance updated"

fromResponse() reads any of the three shapes Steadfast uses: delivery_status from a status check, consignment.status from placeOrder(), and status from a webhook payload. It returns null when the payload carries no status this package recognises, so a status Steadfast adds in future degrades gracefully rather than throwing in production.

DeliveryStatus::fromResponse(SteadfastCourier::placeOrder($order));              // InReview
DeliveryStatus::fromResponse(SteadfastCourier::checkDeliveryStatusByInvoiceId($i)); // Delivered
DeliveryStatus::fromResponse($webhookPayload);                                   // Cancelled
API value Enum case Meaning Bucket
in_review InReview Order placed, under review in progress
pending Pending Not yet delivered or cancelled in progress
hold Hold On hold in progress
delivered_approval_pending DeliveredApprovalPending Delivered, awaiting admin approval awaiting approval
partial_delivered_approval_pending PartialDeliveredApprovalPending Partially delivered, awaiting approval awaiting approval
cancelled_approval_pending CancelledApprovalPending Cancelled, awaiting approval awaiting approval
unknown_approval_pending UnknownApprovalPending Unknown state, needs support awaiting approval
delivered Delivered Delivered and balance updated final
partial_delivered PartialDelivered Partially delivered and balance updated final
cancelled Cancelled Cancelled and balance updated final
unknown Unknown Unknown status, contact support in progress

Every status falls into exactly one bucket: isFinal(), isAwaitingApproval() or isInProgress().

Read the full tracking history

Where a status check returns the current state, this returns the whole event trail:

$response = SteadfastCourier::getTrackingsByInvoice('INV-1001');

foreach ($response['tracking'] as $event) {
    echo $event['text']; // "Consignment created by Sender(API)."
}

Returns and pickups

Create a return request

Identify the consignment by one of consignment_id, invoice or tracking_code. Omitting all three raises InvalidRequestException before any request is sent.

$response = SteadfastCourier::createReturnRequest([
    'consignment_id' => 160757328,
    'reason' => 'Received a damaged or broken item',
]);

List and read return requests

$all = SteadfastCourier::getReturnRequests();
$one = SteadfastCourier::getReturnRequest(1);

Request a pickup

Ask Steadfast to collect parcels from one of your saved addresses. Use getPoliceStations() to look up police_station_id.

$response = SteadfastCourier::createPickupRequest([
    'address_id' => 5,
    'police_station_id' => 92,
    'address' => 'Dhanmondi, Zigatola 2/a',
    'contact_number' => '01722743076',
    'note' => 'Ten small parcels, ready after 3pm.',
    'estim_qty' => 12,
]);

Account and reference data

Current balance

$response = SteadfastCourier::getCurrentBalance();
{
    "status": 200,
    "current_balance": 4820
}

Payments

$all = SteadfastCourier::getPayments();
$one = SteadfastCourier::getPayment('SFC-23618228');
{
    "status": 1,
    "alertClass": "success",
    "message": "Fetched successfully!",
    "payment": {
        "payment_id": "SFC-23618228",
        "amount": 1,
        "method": "Cash",
        "due_bills": 0,
        "paid_bills": 0,
        "charges": 0,
        "total": 1,
        "status_label": "ready",
        "created_at": "2026-02-27 16:08:24",
        "ready_at": "2026-02-27 16:27:20",
        "paid_at": null
    }
}

Police stations

Districts with their police stations, needed for police_station_id on a pickup request:

$response = SteadfastCourier::getPoliceStations();

foreach ($response['data'] as $district) {
    echo $district['name']; // "Bagerhat"

    foreach ($district['policestations'] as $station) {
        echo "{$station['id']}: {$station['name']}";
    }
}

Fraud check

A recipient's delivery and cancellation history across Steadfast. Worth calling before accepting a high-value COD order:

$response = SteadfastCourier::fraudCheck('01309055966');
{
    "total_parcels": 24,
    "total_delivered": 9,
    "total_cancelled": 15,
    "total_fraud_reports": []
}

Multiple Steadfast accounts

If you are not using .env, or you run a SaaS where each tenant has their own Steadfast account, pass credentials per call with withConfig():

$response = SteadfastCourier::withConfig(
    $tenant->steadfast_api_key,
    $tenant->steadfast_secret_key,
    'https://portal.packzy.com/api/v1', // optional, falls back to config
)->getCurrentBalance();

withConfig() returns a new client and never modifies the shared one, so credentials cannot leak between tenants later in the same request, queue worker or Octane worker:

$tenantA = SteadfastCourier::withConfig($a->api_key, $a->secret_key);
$tenantB = SteadfastCourier::withConfig($b->api_key, $b->secret_key);

$tenantA->placeOrder($order); // always tenant A
$tenantB->placeOrder($order); // always tenant B
SteadfastCourier::getCurrentBalance(); // still the credentials from config

Error handling

Steadfast reports its own validation failures as JSON, and that body carries the message you need, so it is returned to you unchanged rather than raised:

$response = SteadfastCourier::placeOrder($order);

if ($response['status'] !== 200) {
    Log::warning('Steadfast rejected the order', ['message' => $response['message']]);
}

Three kinds of problem throw instead:

Exception When
InvalidRequestException Your input cannot succeed. Raised locally, before any network call.
SteadfastCourierException Steadfast answered, but not with JSON: an HTML gateway error, an empty body.
Illuminate\Http\Client\ConnectionException Steadfast could not be reached within the timeout.

InvalidRequestException extends SteadfastCourierException, so one catch covers both.

use Illuminate\Http\Client\ConnectionException;
use SteadFast\SteadFastCourierLaravelPackage\Exceptions\InvalidRequestException;
use SteadFast\SteadFastCourierLaravelPackage\Exceptions\SteadfastCourierException;

try {
    $response = SteadfastCourier::getCurrentBalance();
} catch (InvalidRequestException $e) {
    // A bug in the calling code: bad identifier, oversized batch, missing reference.
    report($e);
} catch (SteadfastCourierException $e) {
    Log::error('Unreadable response from Steadfast', [
        'status' => $e->status(),
        'body' => $e->response()?->body(),
    ]);
} catch (ConnectionException $e) {
    Log::error('Steadfast unreachable', ['message' => $e->getMessage()]);
}

Earlier versions returned null for a non-JSON reply, which callers could not tell apart from an empty result. That case now throws, with the status code and a snippet of the body in the message.

Timeouts and retries

Requests give up after STEADFAST_TIMEOUT seconds (default 30) and stop waiting to connect after STEADFAST_CONNECT_TIMEOUT seconds (default 10). Without these, an unresponsive endpoint would block your web request or queue worker indefinitely.

Status lookups, tracking, balance, payments and reference data are retried up to STEADFAST_RETRY_TIMES times (default 2) when the connection fails.

Writes are never retried. A create_order call that timed out may still have been accepted, and the API has no idempotency key that would make a retry safe, so retrying could book the same parcel twice. If placeOrder(), bulkCreateOrders(), createReturnRequest() or createPickupRequest() throws a ConnectionException, confirm the state with a status lookup before sending again:

try {
    SteadfastCourier::placeOrder($order);
} catch (ConnectionException $e) {
    // Do not simply retry. Find out whether it was created first.
    $existing = SteadfastCourier::checkDeliveryStatusByInvoiceId($order['invoice']);
}

Webhooks

Steadfast pushes parcel status changes to a callback URL you register with them, along with a bearer token of your choosing for authentication.

Set the same token in your .env:

STEADFAST_BEARER_TOKEN="your-generated-bearer-token"

Register a route. Webhooks come from Steadfast's servers, not a browser, so keep it out of any CSRF-protected or session-authenticated group:

Route::post('/steadfast-webhook', [SteadFastWebhookController::class, 'handle']);
use Illuminate\Http\Request;

class SteadFastWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $expected = config('steadfast.webhook_bearer_token');

        // Never authenticate against an unset token. If $expected were empty, the
        // comparison below would collapse to 'Bearer ', which any caller can send.
        if (blank($expected)) {
            return response()->json(['error' => 'Webhook token is not configured'], 500);
        }

        $token = $request->header('Authorization');

        // hash_equals compares in constant time, so the token cannot be recovered
        // by measuring how long the comparison takes.
        if (! is_string($token) || ! hash_equals('Bearer '.$expected, $token)) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        $payload = $request->validate([
            'consignment_id' => ['required'],
            'invoice' => ['required'],
            'status' => ['required', 'string'],
            'cod_amount' => ['required'],
            'updated_at' => ['required'],
        ]);

        // Hand off to your own job so the HTTP response is not blocked.
        ProcessSteadfastWebhook::dispatch($payload);

        // Answer immediately so Steadfast does not time out and resend.
        return response()->json(['status' => 'success']);
    }
}

The status field carries the same values as a status check, so the DeliveryStatus enum applies here too:

$status = DeliveryStatus::tryFrom($payload['status']);

if ($status?->isFinal()) {
    $order->markSettled($status);
}

Steadfast may deliver the same event more than once, so make your handler idempotent: key it on consignment_id and ignore an update whose updated_at is not newer than what you stored.

Upgrading from 1.x

Version 2.0 drops end-of-life framework versions and corrects two behaviours that could lose data or leak credentials. Expect a short checklist rather than a rewrite.

Requirements. PHP 8.2+ and Laravel 12+. See Requirements.

The package has been renamed from steadfast-courier/steadfast-courier-laravel-package to steadfast-courier/steadfast-laravel-package, following the repository rename. The old package is abandoned and will receive no further releases. Swap it in one command:

composer remove steadfast-courier/steadfast-courier-laravel-package
composer require steadfast-courier/steadfast-laravel-package

The config file and its keys have been renamed from steadfast-courier to steadfast. Re-publish the config and rename any keys you read directly:

php artisan vendor:publish --tag="steadfast-config"
- config('steadfast-courier.api_key')
+ config('steadfast.api_key')

Your published config/steadfast-courier.php is no longer read: move any customisations into config/steadfast.php and delete the old file. The STEADFAST_* environment variable names are unchanged, so .env needs no edits.

bulkCreateOrders() posts a different body. It now sends the order list as the request body, matching the current API documentation; it previously sent {"data": "<json string>"}. The response is wrapped, so read $response['data'] instead of iterating the response directly:

// 1.x
foreach (SteadfastCourier::bulkCreateOrders($orders) as $consignment) { }

// 2.0
foreach (SteadfastCourier::bulkCreateOrders($orders)['data'] as $consignment) { }

Non-JSON replies throw. A reply that is not JSON used to come back as null. It now raises SteadfastCourierException. See Error handling.

Invalid input throws early. An empty identifier, a batch over 500 orders, a single order passed where a list belongs, or a return request with no consignment reference now raise InvalidRequestException instead of producing a malformed request.

withConfig() no longer mutates the shared client. Chained calls such as SteadfastCourier::withConfig(...)->placeOrder(...) are unaffected. Previously the credentials you passed leaked into every later call in the same process, which in a multi-tenant application meant booking parcels on the wrong account.

webhook_bearer_token now exists in the config file. If you copied the webhook controller from an older README, re-read Webhooks: the previous example authenticated any request sending a bare Bearer header whenever the token was unset. Re-publish the config, or add the key by hand:

php artisan vendor:publish --tag="steadfast-config" --force

Contributing

Contributions are welcome. Working on the package itself needs PHP 8.3 or newer, since Pest requires it.

composer install
composer test      # Pest
composer analyse   # PHPStan
composer format    # Pint

The test suite never touches the network: it fakes the HTTP client and fails on any unexpected request. Please add a test with your change, and make sure it fails without it.

Bug reports and feature requests belong in GitHub issues.

Credits and license

Built and maintained with thanks to AmadulHaque and all contributors.

The MIT License (MIT). See LICENSE.md.