beziworld / laravel-shoper
Laravel client for the Shoper REST API: multiple shops, token caching with re-authentication, typed orders, products, stocks and customers, parcels, bulk requests, leaky bucket rate limiting and verified webhooks with events.
Requires
- php: ^8.3
- ext-json: *
- guzzlehttp/guzzle: ^7.8
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 17:51:51 UTC
README
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.