adaa / shipping
Framework-agnostic multi-carrier shipping library for PHP with optional Laravel integration. Platform-owned and merchant-owned connections, normalized statuses, idempotent shipments.
Requires
- php: ^8.3
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0|^4.0
- phpstan/phpstan: ^1.0|^2.0
Suggests
- filament/filament: Required only for the optional adaa/shipping-filament admin UI package
- mohamedhekal/shipbridge: Reusable core DTOs and status normalization this package design is inspired by
- mohamedhekal/shipbridge-aramex: Aramex carrier driver (MENA/Global, requires ext-soap)
- mohamedhekal/shipbridge-bosta: Bosta carrier driver (Egypt)
- mohamedhekal/shipbridge-mylerz: Mylerz carrier driver (Egypt/MENA)
- mohamedhekal/shipbridge-smsa: SMSA carrier driver (KSA/GCC, requires ext-soap)
- octw/aramex: Mature alternative Aramex SDK for Laravel
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-13 17:57:52 UTC
README
English | العربية
Multi-carrier shipping for PHP with first-class Laravel support. Connect once per carrier account — the platform's own accounts and every merchant's own accounts stay fully isolated.
composer require adaa/shipping
60-second start (no keys needed)
use Adaa\Shipping\Connection\ShippingConnectionContext; use Adaa\Shipping\Drivers\FakeDriver; use Adaa\Shipping\DTOs\Address; use Adaa\Shipping\DTOs\Parcel; use Adaa\Shipping\DTOs\ShipmentDraft; use Adaa\Shipping\Enums\ConnectionMode; use Adaa\Shipping\Enums\Environment; use Adaa\Shipping\Manager\ShippingManager; $manager = new ShippingManager; $manager->share('fake', new FakeDriver); $connection = new ShippingConnectionContext( connectionId: 'conn_1', carrierCode: 'fake', mode: ConnectionMode::Merchant, environment: Environment::Sandbox, merchantId: 'merchant_7', ); $shipment = $manager->shared('fake')->createShipment(new ShipmentDraft( origin: new Address(name: 'Warehouse', line1: '1 Industrial Rd', city: 'Cairo', countryCode: 'EG'), destination: new Address(name: 'Ahmed Ali', line1: '12 Nile St', city: 'Giza', countryCode: 'EG', phone: '01000000000'), parcels: [new Parcel(weightKg: 1.5, description: 'T-shirt')], reference: 'ORD-1001', ), $connection); echo $shipment->trackingNumber; // e.g. SHP00000001
Core concepts (read once)
| Class | What it is |
|---|---|
ShippingConnectionContext |
One carrier account: connectionId, carrierCode, mode (platform/merchant), environment, credentials (in-memory only), merchantId, storeId, externalAccountId. |
ShippingManager |
Resolves drivers. extend($code, fn ($ctx) => ...) registers a carrier; driverFor($connection) builds it from that connection's credentials; shared($code) returns credential-less drivers (fake). |
CarrierDriverInterface |
What every driver implements: capabilities() + createShipment(). Everything else (SupportsRates, SupportsTracking, SupportsLabels, SupportsCancellation) is opt-in — check capabilities() or instanceof before calling. |
| DTOs | ShipmentDraft (in) → ShipmentResult (id, trackingNumber, status, labelUrl, rate, currency, raw) → TrackingResult (status, events[]) → LabelResult (contents, base64Encoded, url). |
Connecting a real carrier
Register the driver once (e.g. in a service provider), passing each connection's own credentials:
use Adaa\Shipping\Drivers\Bosta\BostaDriver; use Illuminate\Http\Client\Factory as HttpFactory; $manager->extend('bosta', fn ($ctx) => BostaDriver::fromConnection(app(HttpFactory::class), $ctx));
fromConnection() exists on every driver with the same signature. Credentials come from your connection row (encrypted in your DB) — never from .env when you serve multiple merchants.
| Carrier | Credential fields in credentials |
Where to get them |
|---|---|---|
| Bosta | api_key, base_url (https://app.bosta.co, staging https://stg-app.bosta.co) |
Business account → API Integration (docs) |
| Mylerz | username, password (auto-exchanged for a token) or static token |
Business account, request Integration API access (site) |
| SMSA | passkey |
Contracted SECOM account (site) |
| Aramex | username, password, account_number, account_pin, account_entity, account_country_code |
Developer portal, test host ws.dev.aramex.net |
| DHL Express | username, password, account_number (HTTP Basic) |
MyDHL portal |
| FedEx | client_id, client_secret, account_number (OAuth2) |
Developer portal, sandbox apis-sandbox.fedex.com |
| UPS | client_id, client_secret, account_number (OAuth2) |
Developer portal, sandbox wwwcie.ups.com |
| J&T Express Egypt | api_account, customer_code, private_key, customer_pwd |
Merchant onboarding (site) |
| MNG Kargo | client_id, client_secret (IBM app) + customer_number, password (panel, JWT) |
ApiZone portal (site), sandbox testapi.mngkargo.com.tr |
| HepsiJet | username, password (Basic → X-Auth-Token) + abbreviation_code, company_name, company_address_id, current_xdock_code |
Merchant onboarding, sandbox integration-apitest.hepsijet.com |
| Yurtiçi Kargo | ws_user_name, ws_password (+ ws_language, default TR) |
Branch-issued WS credentials (site) |
| Aras Kargo | username, password + customer_code (Esasweb tracking; optional tracking_endpoint) |
Branch SetOrder user + Esasweb (site) |
| Sürat Kargo | kullanici_adi, sifre (+ web_sifre for create) |
Branch-issued SOAP credentials, test prova.suratkargo.com.tr (site) |
| PTT Kargo | musteri_id, sifre (+ kullanici, default PttWs; pre-allocated metadata.barcode) |
PTT corporate WS (site) |
| Egypt Post | — (tracking only, no creation API exists) | — |
Common optional keys every HTTP driver honors: base_url / endpoint override, timeout (seconds), environment (sandbox|production).
Operations reference
All examples assume $driver = $manager->driverFor($connection) and throw Adaa\Shipping\Exceptions\ShippingException on carrier failure.
Create a shipment (+ COD)
$shipment = $driver->createShipment(new ShipmentDraft( origin: new Address(name: 'Store', line1: '1 Industrial Rd', city: 'Cairo', countryCode: 'EG', phone: '01011111111'), destination: new Address(name: 'Sara', line1: '5 Zamalek St', city: 'Cairo', countryCode: 'EG', phone: '01000000000'), parcels: [new Parcel(weightKg: 2.0, description: 'Shoes', quantity: 1)], reference: 'ORD-1002', // your order id metadata: ['cod' => 750], // cash on delivery (carrier converts it) idempotencyKey: 'ord-1002-bosta', // safe to retry with the same key ), $connection); $shipment->id; // carrier shipment id $shipment->trackingNumber; // track with this $shipment->labelUrl; // when the carrier returns one inline
Per-carrier extras go in metadata (see each driver's *PayloadFactory): Bosta city_code/zone/district/building_number/notes/webhook_url; J&T express_type/service_type; UPS service_code (11 = standard); FedEx service_type; Aramex product type, etc.
Get rates (where supported)
if ($driver instanceof SupportsRates) { foreach ($driver->rates($draft, $connection) as $quote) { echo "{$quote->serviceName}: {$quote->amount} {$quote->currency}\n"; // $quote->serviceCode, $quote->estimatedDelivery } }
Track
$tracking = $driver->track('BOSTA-TRACKING-NO', $connection); echo $tracking->status->value; // created|labeled|picked_up|in_transit|out_for_delivery|delivered|exception|cancelled|returned|exchanged foreach ($tracking->events as $event) { echo "{$event->occurredAt} [{$event->location}] {$event->description}\n"; }
Labels
$label = $driver->label($shipment->id, $connection, LabelFormat::Pdf); if ($label->url !== null) { // redirect / store the URL } else { file_put_contents('label.pdf', $label->base64Encoded ? base64_decode($label->contents) : $label->contents); }
Cancel
if ($driver instanceof SupportsCancellation) { $driver->cancel($shipment->id, $connection); // bool }
Returns & exchanges
No separate endpoints — carriers model these as delivery types on creation:
use Adaa\Shipping\Drivers\Bosta\DeliveryType; $return = $driver->createShipment(new ShipmentDraft( origin: $customerAddress, // picked up FROM the customer destination: $warehouseAddress, parcels: [new Parcel(weightKg: 1.0)], reference: 'RET-501', metadata: ['type' => DeliveryType::CRP], // Customer Return Pickup ), $connection); $exchange = $driver->createShipment(new ShipmentDraft( origin: $warehouseAddress, destination: $customerAddress, parcels: [new Parcel(weightKg: 1.0, description: 'Size M')], reference: 'EXC-501', metadata: ['type' => DeliveryType::EXCHANGE], ), $connection);
Two accounts, same carrier (the whole point)
$platform = new ShippingConnectionContext('conn_platform', 'bosta', ConnectionMode::Platform, Environment::Production, ['api_key' => 'PLATFORM_KEY']); $merchant = new ShippingConnectionContext('conn_42', 'bosta', ConnectionMode::Merchant, Environment::Production, ['api_key' => 'MERCHANT_KEY'], merchantId: 'm42'); // Different instances, different credentials, zero shared state: $manager->driverFor($platform)->createShipment($draftA, $platform); $manager->driverFor($merchant)->createShipment($draftB, $merchant);
Laravel
Publish the config, then resolve per request from your connection rows (encrypted credentials in DB):
php artisan vendor:publish --tag=shipping-config
// Migration sketch for your connections table: Schema::create('shipping_connections', function (Blueprint $table) { $table->id(); $table->foreignId('merchant_id')->nullable()->constrained()->cascadeOnDelete(); // NULL = platform $table->string('carrier_code')->index(); $table->string('mode')->default('platform'); // platform|merchant $table->string('environment')->default('sandbox'); // sandbox|production $table->json('credentials')->nullable(); // cast encrypted:array on the model + $hidden $table->string('status')->default('pending'); $table->timestamps(); });
// Resolve inside a job / controller — credentials live only for the call: $context = new ShippingConnectionContext( connectionId: (string) $row->id, carrierCode: $row->carrier_code, mode: $row->mode === 'platform' ? ConnectionMode::Platform : ConnectionMode::Merchant, environment: Environment::from($row->environment), credentials: $row->credentials ?? [], // decrypted via encrypted:array cast merchantId: $row->merchant_id ? (string) $row->merchant_id : null, ); $result = app(ShippingManager::class)->driverFor($context)->createShipment($draft, $context);
Queue slow calls, key jobs by connectionId, and never log $context->credentials. Verify carrier webhooks per connection secret before trusting status updates.
Async jobs, webhooks & diagnostics (built in)
use Adaa\Shipping\Laravel\Contracts\WebhookSecretResolver; use Adaa\Shipping\Laravel\Jobs\CreateShipmentJob; // 1. Queue creation — persist by listening to the events: CreateShipmentJob::dispatch($connection->toArray(), $draft->toArray()); // → Adaa\Shipping\Laravel\Events\ShipmentCreated (connectionId, carrierCode, ShipmentResult) // → Adaa\Shipping\Laravel\Events\ShipmentFailed (connectionId, carrierCode, error, reference) // The job is unique per idempotency key, tries 3 times with backoff, and rethrows for retry. // 2. Webhooks — route auto-registered at {prefix}/{carrier} (default webhooks/shipping). // Bind one resolver; the controller verifies HMAC signatures per connection secret: app()->bind(WebhookSecretResolver::class, fn () => new class implements WebhookSecretResolver { public function secretFor(string $carrierCode, array $payload): ?string { return ShippingConnection::where('carrier_code', $carrierCode)->value('webhook_secret'); } }); // → Adaa\Shipping\Laravel\Events\WebhookReceived (carrierCode, trackingNumber, status, payload) // Drivers may also implement HandlesWebhooks for custom schemes. Unsigned calls are // rejected unless shipping.webhook.allow_unsigned is true (dev only).
php artisan shipping:drivers # registered drivers + declared capabilities php artisan shipping:diagnose bosta # registry metadata + required credentials (no live calls)
Statuses & errors
Carrier strings (OFD, shipped, …) normalize to ShipmentStatus; unknown values become exception instead of throwing, so webhooks stay resilient. ShippingException::carrierFailed($message, $status) carries the HTTP status; shipmentNotFound() uses 404. Drivers strip secrets from raw before returning it — still, never dump raw to logs in production.
Testing your integration
// Unit / feature tests: FakeDriver is deterministic. $driver = new FakeDriver; $shipment = $driver->createShipment($draft, $connection); $driver->advance($shipment->trackingNumber, ShipmentStatus::Delivered); $this->assertEquals(ShipmentStatus::Delivered, $driver->track($shipment->trackingNumber, $connection)->status); // HTTP-level tests: inject Illuminate\Http\Client\Factory and fake it. $http = new Factory; $http->fake(['*' => Http::response([...], 200)]); $driver = BostaDriver::fromConnection($http, $connection);
Supported drivers
Bosta, Mylerz, SMSA, Aramex, DHL Express, FedEx, UPS, J&T Express Egypt — full create/track/label (plus cancel/return/exchange/COD where the carrier API supports it; check $driver->capabilities()); MNG Kargo, Yurtiçi Kargo, Sürat Kargo, PTT Kargo — create/track/cancel/return/exchange/COD, labels honestly disabled (ZPL/EPL only at create time or barcode-print; check $driver->capabilities()); HepsiJet — create/track/label/return/exchange/COD (no cancel endpoint); Aras Kargo — create/track/label/cancel/return/exchange/COD; Egypt Post tracking only; FakeDriver for tests. Adding a carrier = new driver + extend(), core untouched.
Companion admin UI: adaa/shipping-filament (optional Filament resources + dashboard).
Contributing
MIT. One rule: a test for every change, Pint before PRs, and never declare an operation a carrier does not support — verify against the real API and note the evidence.
License
MIT