ecomalo / ecomalo-php
Connect a Laravel or plain PHP shop to EcomAlo: send orders, receive signed status and tracking webhooks, sync products and stock.
Requires
- php: ^8.1
- ext-json: *
- guzzlehttp/guzzle: ^7.5 || ^8.0
Requires (Dev)
None
Suggests
- illuminate/support: For the Laravel service provider, facade, config and webhook route (Laravel 10+).
Provides
None
Conflicts
None
Replaces
None
README
Connect your own Laravel or plain PHP shop to EcomAlo, the same way the EcomAlo WooCommerce plugin does:
- send each new order to EcomAlo for confirmation, fraud check and courier booking
- receive order status and parcel/tracking updates back as signed webhooks
- send your product list (with your stock counts) so the owner can link products in EcomAlo
- optionally read and change EcomAlo stock, look up orders and parcels (with a merchant API key)
Requires PHP 8.1+ and Guzzle 7. Laravel 10, 11, 12 and 13 are supported through auto-discovery.
1. Get a connection code
In the EcomAlo panel: Integrations > Connect site, type Laravel (or Custom for plain PHP), enter your shop address, and press connect. EcomAlo shows a one-time connection code. It works once and expires after 24 hours.
2. Install
composer require ecomalo/ecomalo-php
Laravel
Connect
php artisan ecomalo:connect <code> --write
This trades the code for your site's credentials and writes them to .env:
ECOMALO_API_KEY=... # the site token ECOMALO_API_SECRET=... # the webhook secret
It also tells EcomAlo where to send updates: APP_URL/ecomalo/webhook by default (change it with --webhook-url=, and the shop address with --site-url=). Without --write it only prints the two lines; they are shown once.
Optional settings:
ECOMALO_BASE_URL=https://api.ecomalo.com/v1 # default ECOMALO_MERCHANT_KEY= # merchant API key, see "Merchant API" below ECOMALO_TIMEOUT=15 ECOMALO_RETRIES=2 ECOMALO_WEBHOOK_PATH=ecomalo/webhook ECOMALO_WEBHOOK_ENABLED=true
To change more, publish the config file:
php artisan vendor:publish --tag=ecomalo-config
If you cache config, run php artisan config:cache after editing .env.
Send an order
Send orders from a queued job, never inside the checkout request, so a slow network can never hold up a buyer.
use EcomAlo\Laravel\Facades\EcomAlo; EcomAlo::orders()->send([ 'external_id' => (string) $order->id, // required, your order id 'external_number' => $order->number, 'customer_name' => $order->name, // required 'phone' => $order->phone, // required 'alt_phone' => null, 'email' => $order->email, 'address' => $order->address, // required 'district' => 'Dhaka', 'thana' => 'Dhanmondi', 'delivery_charge' => 60, 'discount' => 0, 'customer_note' => $order->note, 'paid_amount' => 0, // 0 for cash on delivery 'payment_method' => null, // e.g. "bKash" when paid_amount > 0 'items' => [ [ 'external_product_id' => '12', 'external_variant_id' => '34', // null for a simple product 'sku' => 'TSHIRT-RED-L', 'name' => 'T-shirt Red L', // required 'qty' => 2, // required 'unit_price' => 450, // required, price before the order discount ], ], 'tracking' => [ // optional, for Meta conversion tracking 'fbp' => $request->cookie('_fbp'), 'fbc' => $request->cookie('_fbc'), 'ip' => $request->ip(), 'user_agent' => $request->userAgent(), 'utm_source' => null, 'utm_medium' => null, 'utm_campaign' => null, 'landing_url' => null, ], ]);
EcomAlo queues the order and answers at once. The same external_id never makes a second order, so retries are safe. To record a payment made later (for example after an online payment), send the same order again with the new, higher paid_amount; nothing else on the order changes.
Cancelled on your side:
EcomAlo::orders()->cancel($order->id);
An order already handed to a courier is not cancelled; this throws ValidationException with remark cannot_cancel.
Receive updates (webhooks)
The package registers POST /ecomalo/webhook, checks the signature, and fires Laravel events. Listen to the ones you need:
Event class (EcomAlo\Laravel\Events\…) |
EcomAlo event | Sent automatically |
|---|---|---|
WebhookReceived |
every event | |
OrderStatusChanged |
order.status_changed |
yes |
OrderCancelled |
order.cancelled |
yes |
ParcelBooked |
parcel.booked |
yes |
ParcelStatusChanged |
parcel.status_changed |
yes |
ParcelDelivered |
parcel.delivered |
yes |
ParcelReturned |
parcel.returned |
yes |
OrderCreated |
order.created |
add on the Webhooks page |
StockChanged |
stock.changed |
add on the Webhooks page |
use EcomAlo\Laravel\Events\OrderStatusChanged; use EcomAlo\Laravel\Events\ParcelBooked; use Illuminate\Support\Facades\Event; Event::listen(function (OrderStatusChanged $e) { Order::where('id', $e->webhook->externalId())->update(['ecomalo_status' => $e->webhook->orderStatus()]); }); Event::listen(function (ParcelBooked $e) { Order::where('id', $e->webhook->externalId())->update([ 'courier' => $e->webhook->get('courier'), // steadfast, pathao, redx, ... 'tracking_code' => $e->webhook->trackingCode(), ]); });
Order statuses: pending, hold, confirmed, ready, booked, shipped, delivered, partial, returned, cancelled, lost.
Payloads ($e->webhook->data):
order.*:order{uid, number, external_id, status, grand_total, cod_amount},happened_at; status changes addfrom,to,to_labelparcel.*: tracking_code, order_uid, order_number, external_id, courier, consignment_id, status, cod_amount, updated_atstock.changed:variants[{uid, sku}],happened_at
EcomAlo waits 10 seconds for a 2xx answer and retries a failed delivery later with the same delivery id. The controller answers a repeated id without firing events again (for ECOMALO_WEBHOOK_DEDUPE_MINUTES, default one day, using your cache). Put slow work in listeners that implement ShouldQueue. If a listener throws, the delivery is not marked as seen, so EcomAlo's retry runs it again.
The route has no web middleware, so no CSRF token is needed. To use your own route instead, set ECOMALO_WEBHOOK_ENABLED=false and call WebhookVerifier yourself (see plain PHP below).
Products and stock
Send your catalogue once, and again when products change. One row per sellable item:
EcomAlo::products()->sync( Product::with('variants')->cursor()->flatMap(fn ($p) => $p->variants->map(fn ($v) => [ 'external_product_id' => (string) $p->id, 'external_variant_id' => (string) $v->id, // null for a simple product 'sku' => $v->sku, 'title' => $p->name.' '.$v->name, 'stock' => $v->stock, // your own count, or null ])) ); // returns the number of rows saved; sent 500 per call
The owner then links each row to an EcomAlo product (automatically by SKU, or by hand). EcomAlo::stock()->push($rows) is the same call, for when only the counts changed.
Other calls
EcomAlo::status(); // connection status, store, last_order_at, panel settings EcomAlo::fraudCheck('01711000000'); // ['checking' => true] the first time, then the score EcomAlo::disconnect(); // revokes the token; imported orders stay
Plain PHP
require 'vendor/autoload.php'; use EcomAlo\Client; // once: trade the code, then keep both values somewhere safe (they are shown only once) $connection = Client::connect('<code>', 'https://myshop.com', 'https://myshop.com/ecomalo-webhook.php'); // $connection->apiKey, $connection->webhookSecret, $connection->storeName, $connection->settings $ecomalo = new Client([ 'api_key' => getenv('ECOMALO_API_KEY'), 'merchant_key' => getenv('ECOMALO_MERCHANT_KEY') ?: null, // optional 'base_url' => 'https://api.ecomalo.com/v1', // default 'timeout' => 15, 'connect_timeout' => 5, 'retries' => 2, ]); $ecomalo->orders()->send([...]); // same fields as the Laravel example $ecomalo->orders()->cancel('1001'); $ecomalo->products()->sync([...]);
Webhook endpoint (ecomalo-webhook.php):
require 'vendor/autoload.php'; use EcomAlo\Exceptions\InvalidSignatureException; use EcomAlo\Webhook\WebhookVerifier; try { $event = (new WebhookVerifier(getenv('ECOMALO_API_SECRET')))->verifyCurrentRequest(); } catch (InvalidSignatureException $e) { http_response_code(401); exit; } if ($event->is('order.status_changed')) { // $event->externalId(), $event->orderStatus() } if ($event->is('parcel.booked')) { // $event->trackingCode(), $event->get('courier') } echo '{"ok":true}';
With a framework request object, pass the raw body and headers instead: $verifier->verify($rawBody, $headers).
How the signature works
Each webhook carries X-EcomOS-Event, X-EcomOS-Delivery, X-EcomOS-Timestamp and X-EcomOS-Signature. The signature is sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your webhook secret. Calls older than 5 minutes are refused, so make sure your server clock is right.
Merchant API (optional)
Some calls use a merchant API key instead of the site token. Create one in the API keys page of the EcomAlo panel for the same store, with the scopes you need, and set merchant_key / ECOMALO_MERCHANT_KEY.
EcomAlo::orders()->find('1001'); // EcomAlo's order for your id, or null while still queued (orders:read) EcomAlo::orders()->get($uid); // by EcomAlo uid (orders:read) EcomAlo::orders()->list(['status' => 'shipped', 'updated_since' => '2026-11-01', 'per_page' => 50]); EcomAlo::products()->list(['sku' => 'TSHIRT-RED-L']); // products:read EcomAlo::stock()->levels(['TSHIRT-RED-L']); // available in the store's warehouse (stock:read) EcomAlo::stock()->adjust('TSHIRT-RED-L', -2, 'damaged'); // change EcomAlo stock by a delta (stock:write) EcomAlo::tracking('DH123456'); // parcel status and events (tracking:read)
To keep your shop's stock in step with EcomAlo, add a stock.changed webhook on the Webhooks page, listen to StockChanged, and read the new counts with stock()->levels() for the SKUs in $e->webhook->get('variants').
A call that needs the merchant key throws EcomAloException when it is not set.
Errors
Everything thrown extends EcomAlo\Exceptions\EcomAloException.
| Exception | When |
|---|---|
ConnectionException |
EcomAlo could not be reached after all retries; try again later |
AuthenticationException (401) |
wrong or revoked token, or the site was disconnected in the panel |
PermissionException (403) |
merchant key lacks the scope, or the subscription is inactive |
NotFoundException (404) |
no such order, SKU or parcel |
ValidationException (422) |
the payload was refused; getErrors() lists why |
RateLimitException (429) |
too many calls (300 a minute per site, 120 per merchant key) |
ServerException (5xx) |
EcomAlo failed on its side |
InvalidSignatureException |
a webhook did not verify |
All API exceptions have getStatus(), getRemark(), getErrors(), getBody() and isRetryable(). A missing required order field throws InvalidArgumentException before any network call.
Lost connections, 429 and 5xx answers are retried (retries, default 2, with 0.5s, 1s... back-off and Retry-After). stock()->adjust() is a change, not a total, so it is only retried when the connection was refused before anything was sent.
License
MIT