Search by

beziworld / laravel-shoper

kamcw

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.

Package info

github.com/Beziworld/laravel-shoper

Homepage

Issues

pkg:composer/beziworld/laravel-shoper

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-04 17:51 UTC

This package is auto-updated.

Last update: 2026-11-04 17:51:51 UTC


README

tests Latest Version License

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 CarbonImmutable in the shop time zone
  • lazy iterators over every list (page, limit up to 50, JSON filters, 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 with Retry-After handled without ever retrying writes
  • webhooks: route with X-Webhook-Sha1 verification, JSON and XML payloads, Laravel event

Polska wersja dokumentacji

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.

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.