laravel-shoper maintained by beziworld
Laravel Shoper
Shoper REST API client for Laravel. Orders, products, stocks, customers and parcels of one or many Shoper shops:
- authentication with an API user (
POST /webapi/rest/auth, token cached until it expires) or with AppStore OAuth tokens refreshed automatically; a request answered with 401 is repeated once with a new token - multiple shops: named connections and runtime credentials stored per tenant (agencies, SaaS)
- typed, immutable objects for orders, order lines, products, stocks, customers, parcels and statuses;
amounts are decimal strings, the ISO currency code of orders is resolved for you, dates are
CarbonImmutablein the shop time zone - lazy iterators over every list (
page,limitup to 50, JSONfilters,order) and generic CRUD for every other resource (taxes,warehouses,order-refunds, ...) - bulk helper for
POST /bulk(25 operations per call, longer lists split), used for mass stock updates - leaky bucket pacing from
X-Shop-Api-Calls/X-Shop-Api-Limit/X-Shop-Api-Bandwidth, 429 withRetry-Afterhandled without ever retrying writes - webhooks: route with
X-Webhook-Sha1verification, JSON and XML payloads, Laravel event
What this package adds. Packagist has the official framework-agnostic SDKs (dreamcommerce/shop-appstore-lib,
shoper/sdk) and Symfony bundles, but no Laravel package. This one brings Laravel conventions to Shoper:
configuration per shop with a facade, tokens in the Laravel cache shared by workers, Http::fake() friendly
transport, typed objects with decimal amounts, lazy pagination, bulk writes, request pacing and webhooks
dispatched as events.
Installation
composer require beziworld/laravel-shoper
php artisan vendor:publish --tag=shoper-config # optional
SHOPER_SHOP=sklep123.shoparena.pl # or the shop domain
SHOPER_LOGIN=api-user # administrator with API access (Configuration > Administration > Administrators)
SHOPER_PASSWORD=secret
Requires PHP 8.3+ and Laravel 12 or 13. The token from /auth is cached per shop and user in the Laravel cache
(SHOPER_CACHE_STORE; use a store shared by every server and worker) until five minutes before expires_in.
AppStore applications use OAuth tokens instead: SHOPER_ACCESS_TOKEN, plus SHOPER_REFRESH_TOKEN,
SHOPER_CLIENT_ID and SHOPER_CLIENT_SECRET to renew it. After a refresh the package dispatches
TokenRefreshed with the new pair (the refresh token changes): persist it.
// AppStore installation callback: exchange the authorization code for tokens
$token = Shoper::using(['shop' => $shopUrl, 'access_token' => 'pending', 'client_id' => $id, 'client_secret' => $secret])
->exchangeAuthorizationCode($request->input('code'));
$token->accessToken; $token->refreshToken; $token->expiresAt; $token->scopes;
Multiple shops
'connections' => [
'default' => ['shop' => env('SHOPER_SHOP'), 'login' => env('SHOPER_LOGIN'), 'password' => env('SHOPER_PASSWORD')],
'outlet' => ['shop' => 'outlet.pl', 'login' => 'api', 'password' => env('SHOPER_OUTLET_PASSWORD')],
],
use Beziworld\Shoper\Facades\Shoper;
Shoper::statuses()->all(); // default connection
Shoper::connection('outlet')->products()->findByCode('MUG-1');
// Credentials stored per tenant; the name identifies the shop in events
$client = Shoper::using(['shop' => $shop->domain, 'login' => $shop->api_login, 'password' => $shop->api_password], 'shop-'.$shop->id);
Orders
foreach (Shoper::orders()->iterate(['status_id' => 2], 'date desc') as $order) {
$order->id; // 15
$order->statusId; $order->statusName; // 2, "W realizacji" (pl_PL translation)
$order->createdAt; // CarbonImmutable, Europe/Warsaw
$order->currency; // "PLN", resolved from currency_id once per client
$order->sum; // "275.97"
$order->paid; $order->amountDue(); // "100.00", "175.97"
$order->isPaid; $order->cashOnDelivery;
$order->billingAddress->taxId; $order->deliveryAddress->street();
$order->raw; // the original record, including fields not mapped
}
$order = Shoper::orders()->findWithLines(15); // ?Order with $order->lines (order-products)
foreach ($order->lines as $line) { $line->code; $line->quantity; $line->price; $line->total(); }
Shoper::orders()->setStatus(15, 3);
Shoper::orders()->update(15, ['notes_priv' => 'Packed by Anna']);
Filters are JSON-encoded for you; use the operator syntax for ranges, for example
['date' => ['>=' => '2026-11-01 00:00:00']].
Synchronising orders with the scheduler
// routes/console.php
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
// New orders by id cursor
$lastId = Cache::get('shoper:orders:last-id', 0);
Shoper::orders()->afterId($lastId)->each(function (Order $order) use (&$lastId) {
LocalOrder::updateOrCreate(['shoper_id' => $order->id], [
'status_id' => $order->statusId,
'total' => $order->sum, // store as DECIMAL, no float rounding
'currency' => $order->currency,
]);
$lastId = max($lastId, $order->id);
});
Cache::forever('shoper:orders:last-id', $lastId);
// Status changes of existing orders
$since = Cache::get('shoper:orders:status-since', now()->subDay());
$started = now();
Shoper::orders()->since($since, 'status_date')->each(fn (Order $order) => LocalOrder::where('shoper_id', $order->id)
->update(['status_id' => $order->statusId]));
Cache::forever('shoper:orders:status-since', $started->subMinutes(2));
})->everyFiveMinutes()->withoutOverlapping();
Webhooks (below) deliver the same changes immediately; keep the scheduled sync as a safety net.
Products, stocks and prices
foreach (Shoper::products()->iterate(['producer_id' => 4]) as $product) {
$product->id; $product->code; $product->name; // name in pl_PL (all locales in $product->names)
$product->price(); // gross price of the basic stock, "49.90"
$product->stock?->stock; // quantity of the basic stock
}
Shoper::products()->modifiedSince(now()->subHour());
Shoper::productStocks()->forProduct(31); // basic stock and variants (ProductStock)
$variant = Shoper::productStocks()->findByCode('MUG-RED');
Shoper::productStocks()->setStock($variant->id, 12);
Shoper::productStocks()->setPrice($variant->id, 44.90);
// Many stocks at once: bulk calls of 25 operations
$result = Shoper::productStocks()->updateMany([
90 => 12, // quantity
91 => ['stock' => 3, 'price' => 44.90], // any fields
]);
$result->successful(); $result->failed(); // list<BulkItem>: id, code, body, error()
With warehouses enabled the stock quantity is the read-only sum of the warehouses; update the warehouses instead.
Parcels and tracking numbers
$parcelId = Shoper::parcels()->createForOrder(15, trackingNumber: '6200123456789', shippingId: 3);
Shoper::parcels()->setTrackingNumber($parcelId, '6200123456790');
Shoper::parcels()->markSent($parcelId); // may change the order status as configured in the shop
Shoper::orders()->parcels(15); // list<Parcel>: trackingNumber, sent, sentAt
Customers, statuses and other resources
Shoper::customers()->findByEmail('jan@example.pl'); // ?Customer
Shoper::statuses()->all(); // list<StatusDefinition>
Shoper::categories()->iterate(); // arrays
Shoper::shippings()->list(); Shoper::payments()->list(); Shoper::producers()->create(['name' => 'ACME']);
Shoper::resource('taxes')->find(1); // any list resource, generic CRUD
Shoper::request('GET', 'application-config'); // any endpoint
Bulk requests
$result = Shoper::bulk()
->get('orders/15', id: 'order')
->get('order-products', ['filters' => ['order_id' => 15]], id: 'lines')
->put('product-stocks/90', ['stock' => 3])
->send();
$result->item('order')?->body;
Paths are relative to /webapi/rest. More than 25 operations are sent in consecutive calls; operations inside a
bulk call are never retried.
Webhooks
Add a webhook in the panel (Integrations > Webhooks): URL https://your-app/shoper/webhook (or
.../shoper/webhook/outlet for a named connection), events, JSON or XML format and a key.
SHOPER_WEBHOOKS_ENABLED=true
SHOPER_WEBHOOK_SECRET=the-key-from-the-panel
Every delivery is verified with X-Webhook-Sha1 = sha1(X-Webhook-Id + ":" + key + ":" + body) and dispatched as
WebhookReceived:
use Beziworld\Shoper\Enums\WebhookEvent;
use Beziworld\Shoper\Events\WebhookReceived;
Event::listen(function (WebhookReceived $event) {
match ($event->event) {
WebhookEvent::OrderCreate, WebhookEvent::OrderStatus => SyncOrder::dispatch($event->connection, $event->order()->id),
WebhookEvent::ParcelDispatch => $event->parcel()?->trackingNumber,
default => null,
};
});
Events: order.create|edit|paid|status|delete, client.create|edit|delete, product.create|edit|delete,
parcel.create|dispatch|delete, subscriber.create|edit|delete. Deliveries can be resent from the panel, so
listeners should be idempotent. For tenant shops call
app(WebhookHandler::class)->handle($request, $tenant->webhook_secret, 'shop-7') from your own controller.
Rate limits
The API uses a leaky bucket per shop: X-Shop-Api-Limit operations fit in the bucket (10 observed), which
drains by X-Shop-Api-Bandwidth per second (2 observed; /auth has its own bucket of 5 draining 1 per second).
The client records these headers and waits before a request that would overflow the bucket
(SHOPER_THROTTLE=true, state per process; Shoper::rateLimit() shows the last state). A 429 is retried once
for reads when Retry-After is at most SHOPER_RETRY_MAX_WAIT seconds; otherwise RateLimitExceeded is thrown.
Writes are never retried, except once after a 401 with a new token (the API rejected the request before
processing it).
Errors
| Exception | When | Details |
|---|---|---|
AuthenticationFailed |
/auth or token refresh rejected, 401 after re-authentication, 403 |
error() (auth_failure, unauthorized_client), errorDescription(), httpStatus() |
ApiException |
any other HTTP error, non-JSON body, connection failure | error(), errorDescription() (string or messages per field), httpStatus(), isNotFound(), isValidationError(), isLocked(), method(), path(), body() |
RateLimitExceeded |
429 not retried | retryAfter() |
InvalidSignature |
wrong X-Webhook-Sha1 or no secret configured |
|
BulkLimitExceeded |
unsupported bulk method | |
InvalidConfiguration |
missing shop or credentials, unknown connection |
find() returns null for 404. All exceptions extend Beziworld\Shoper\Exceptions\ShoperException.
Development
composer install
composer ci # pint --test, phpstan (level 8, strict rules), pest
Tests use Http::fake() with stray requests prevented; nothing is sent to a real shop.
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an e-commerce integration? Get in touch through beziworld.eu.
License
MIT. See LICENSE.md. This package is not affiliated with Shoper S.A.