Looking to hire Laravel developers? Try LaraJobs

laravel-allegro maintained by beziworld

Description
Allegro REST API client for Laravel: OAuth2 (code with PKCE, device flow, client credentials), multiple seller accounts, orders and offer event polling, fulfillment, tracking numbers, Wysylam z Allegro shipments, billing, messaging and returns.
Last update
2026/11/04 17:23 (dev-master)
License
Downloads
1

Comments
comments powered by Disqus

Laravel Allegro

tests Latest Version License

Allegro REST API client for Laravel, built for shops and SaaS platforms that serve one or many Allegro sellers:

  • OAuth2: authorization code (with PKCE), device flow and client credentials; rotating refresh tokens are refreshed under a lock and kept in a pluggable token store
  • many seller accounts on one application: named accounts in the config and sellers connected at runtime
  • orders: event journal polling with Laravel events, checkout forms, fulfillment status, tracking numbers, invoices
  • offers: listing, create and edit through /sale/product-offers (with async operation polling), batch price and quantity changes, offer event journal
  • Wysyłam z Allegro: shipments, labels (PDF/ZPL, A4/A6), protocols, courier pickups
  • billing entries, message center, customer returns (beta), categories, parameters and the product catalogue
  • typed, immutable DTOs (Money keeps amounts as strings) and a generic request() for every other endpoint

Polska wersja dokumentacji

Installation

composer require beziworld/laravel-allegro
php artisan vendor:publish --tag=allegro-config   # optional

Register an application at apps.developer.allegro.pl (sandbox: apps.developer.allegro.pl.allegrosandbox.pl).

ALLEGRO_CLIENT_ID=your-client-id
ALLEGRO_CLIENT_SECRET=your-client-secret
ALLEGRO_SANDBOX=false                                    # true = *.allegrosandbox.pl
ALLEGRO_REDIRECT_URI=https://shop.example/allegro/callback
ALLEGRO_USER_AGENT="MyShop/1.0 (+https://shop.example)"  # mandatory, see below
ALLEGRO_CACHE_STORE=redis                                # shared store for tokens, locks and cursors

Allegro requires every integration to send a User-Agent in the format AppName/Version (+https://info-page-url), consistent with the name of the registered application; the package refuses to start without it. Requests also carry Accept/Content-Type: application/vnd.allegro.public.v1+json (the beta media type for beta resources) and Accept-Language (ALLEGRO_LANGUAGE, default pl-PL), which sets the language of error messages.

Connecting a seller

Authorization code with PKCE

use Beziworld\Allegro\Facades\Allegro;
use Beziworld\Allegro\OAuth\Pkce;

// Redirect
$verifier = Pkce::generateVerifier();
session(['allegro_verifier' => $verifier, 'allegro_state' => $state = Str::random(40)]);

return redirect(Allegro::authorizationUrl($state, $verifier));

// Callback (the code is valid for a few seconds)
abort_unless($request->query('state') === session('allegro_state'), 403);

Allegro::exchangeCode($request->query('code'), session('allegro_verifier'));            // default account
Allegro::forUser('seller-' . $seller->id)->exchangeCode($request->query('code'), session('allegro_verifier')); // SaaS

Without PKCE call authorizationUrl($state) and exchangeCode($code). Pass scopes as the third argument (or set allegro.scopes) to request fewer permissions, and 'confirm' as the fourth one to show the account confirmation screen.

Device flow

For back-office tools and terminals (the application must be registered as a device application):

php artisan allegro:device-login            # default account
php artisan allegro:device-login second     # named account
php artisan allegro:device-login --user=seller-42

Or in code:

$code = Allegro::startDeviceFlow();
// show $code->verificationUri and $code->formattedUserCode(), or the clickable $code->verificationUriComplete

$token = Allegro::forUser('seller-42')->waitForDeviceToken($code);   // honours "interval" and "slow_down"
// or poll yourself: Allegro::forUser('seller-42')->pollDeviceToken($code)->isGranted()

Application token

Public resources (categories, parameters, carriers, billing types) accept a client credentials token:

Allegro::application()->catalog()->categories();

Tokens

Access tokens live 12 hours; refresh tokens live 3 months and are single use: every refresh returns a new pair and the previous refresh token stays valid for only 60 seconds. The client refreshes expired tokens under a cache lock, re-reads the store after acquiring the lock, and stores the new pair immediately. After HTTP 401 it refreshes once and repeats the request.

Tokens are kept in the cache by default (CacheTokenStore, 90-day TTL). To keep them in your database, implement Beziworld\Allegro\Contracts\TokenStore (get, put, forget; the key is the account name or the forUser() key) and set allegro.token_store to your class, or bind the interface in the container. An account can also be seeded with a refresh token in the config (ALLEGRO_REFRESH_TOKEN).

When Allegro revokes the authorization (password change, unlinking the application, expired refresh token), calls throw AuthenticationException with requiresReauthorization() === true: ask the seller to connect again.

Multiple seller accounts

One application serves any number of sellers, each with its own tokens:

// config/allegro.php
'accounts' => [
    'default' => ['refresh_token' => env('ALLEGRO_REFRESH_TOKEN')],
    'outlet' => ['token_key' => 'outlet-shop', 'language' => 'en-US'],
],
Allegro::orders()->checkoutForms();                       // default account
Allegro::account('outlet')->orders()->checkoutForms();    // named account
Allegro::forUser('seller-42')->orders()->checkoutForms(); // seller connected at runtime (SaaS)
Allegro::forUser('seller-42')->isConnected();
Allegro::forUser('seller-42')->disconnect();

Account entries may override any shared option, including client_id, client_secret and sandbox.

Orders

Polling new orders

Allegro has no webhooks; changes are read from event journals. allegro:poll-orders reads /order/events from the last stored event id and dispatches OrderEventReceived for every new event. On the first run of an account it starts at the newest event (set allegro.events.start to beginning to replay the last 60 days).

// routes/console.php
Schedule::command('allegro:poll-orders --all')->everyMinute()->withoutOverlapping();
// sellers connected at runtime: --user=seller-1 --user=seller-2, or call the poller yourself:
// app(OrderEventPoller::class)->poll(Allegro::forUser($key));
use Beziworld\Allegro\Data\OrderEvent;
use Beziworld\Allegro\Events\OrderEventReceived;
use Beziworld\Allegro\Facades\Allegro;

Event::listen(function (OrderEventReceived $received): void {
    if ($received->event->type !== OrderEvent::READY_FOR_PROCESSING) {
        return;
    }

    $order = Allegro::forUser($received->account)->orders()->checkoutForm($received->event->checkoutFormId);

    $order->buyer->login;
    $order->delivery?->address?->fullName();
    $order->delivery?->pickupPointId;          // parcel locker or pickup point
    $order->invoice?->taxId;                   // NIP when the buyer wants an invoice
    $order->payment?->isCashOnDelivery();
    $order->totalToPay?->amount;               // "123.45" (string)
    foreach ($order->lineItems as $item) {
        $item->externalId;                     // your SKU (offer external.id)
        $item->quantity;
        $item->price?->amount;
    }
});

The cursor is saved after each event and a lock prevents overlapping runs. Allegro may repeat events or deliver them out of order, so key your processing on checkoutFormId and make listeners idempotent. Address and invoice data are final only at READY_FOR_PROCESSING. allegro:poll-offers does the same for /sale/offer-events (OfferEventReceived; the journal keeps 24 hours).

Listing and reading orders

$page = Allegro::orders()->checkoutForms([
    'status' => 'READY_FOR_PROCESSING',
    'fulfillment.status' => ['NEW', 'PROCESSING'],
    'lineItems.boughtAt.gte' => now()->subDay(),
    'sort' => '-lineItems.boughtAt',
], limit: 100, offset: 0);                      // $page->checkoutForms, $page->totalCount

foreach (Allegro::orders()->allCheckoutForms(['status' => 'READY_FOR_PROCESSING']) as $order) {
    // every page, within the 10 000 orders window of the API
}

Fulfillment and tracking numbers

use Beziworld\Allegro\Enums\FulfillmentStatus;

Allegro::orders()->setFulfillmentStatus($order->id, FulfillmentStatus::ReadyForShipment, $order->revision);
// a stale revision throws ApiException with isConflict() === true

Allegro::orders()->carriers();                                         // ids such as INPOST, DPD, DHL, POCZTA_POLSKA
Allegro::orders()->addTrackingNumber($order->id, 'INPOST', '620000000000000');
Allegro::orders()->addTrackingNumber($order->id, 'OTHER', 'X123', 'Local courier', [$lineItemId]);
Allegro::orders()->shipments($order->id);
Allegro::orders()->setFulfillmentStatus($order->id, FulfillmentStatus::Sent);

Invoices

$documentId = Allegro::orders()->uploadInvoice($order->id, $pdfContents, invoiceNumber: 'FV/1/11/2026');
Allegro::orders()->addBillingDocumentLink($order->id, 'https://shop.example/invoices/1.pdf');
Allegro::orders()->billingDocuments($order->id);

Offers

use Beziworld\Allegro\Data\Money;

$page = Allegro::offers()->list(['publication.status' => ['ACTIVE'], 'external.id' => 'SKU-1'], limit: 1000);
$offer = Allegro::offers()->get('7712345678');           // ProductOffer: price, stockAvailable, status, raw

$result = Allegro::offers()->create([
    'productSet' => [['product' => ['id' => $productId]]],
    'sellingMode' => ['price' => Money::of(49.99)->toArray()],
    'stock' => ['available' => 10],
    'external' => ['id' => 'SKU-1'],
    'publication' => ['status' => 'ACTIVE'],
]);
$offer = Allegro::offers()->waitForOperation($result);   // polls the operation when Allegro answered 202

Allegro::offers()->edit('7712345678', ['stock' => ['available' => 5]]);

// Batch commands (a UUID commandId is generated unless you pass one)
$report = Allegro::offers()->changePrice(['7712345678', '7712345679'], Money::of('39.90'));
$report = Allegro::offers()->changeQuantity(['7712345678'], 3, 'GAIN');
Allegro::offers()->priceChangeReport($report->id)->isCompleted();
Allegro::offers()->priceChangeTasks($report->id);         // per-offer SUCCESS / FAIL with errors

Wysyłam z Allegro

$wza = Allegro::shipmentManagement();

$proposal = $wza->deliveryProposals($order->id);         // recommended input for the shipment
$command = $wza->createShipment([
    'deliveryMethodId' => $order->delivery->methodId,
    'sender' => [...], 'receiver' => [...],
    'packages' => [['type' => 'PACKAGE',
        'length' => ['value' => 30, 'unit' => 'CENTIMETER'], 'width' => ['value' => 20, 'unit' => 'CENTIMETER'],
        'height' => ['value' => 10, 'unit' => 'CENTIMETER'], 'weight' => ['value' => 1.5, 'unit' => 'KILOGRAMS']]],
    'labelFormat' => 'PDF',
]);
$shipment = $wza->waitForShipment($command);              // Shipment: id, carrier, waybills

$pdf = $wza->label([$shipment->id], 'A6');                // null when no label is available
$protocol = $wza->protocol([$shipment->id]);              // null for carriers without protocols
$slots = $wza->pickupProposals([$shipment->id], $pickupAddress, now()->addDay());
$wza->createPickup([$shipment->id], $pickupAddress, ['date' => '2026-11-05', 'minTime' => '10:00', 'maxTime' => '14:00']);
$wza->waitForCancellation($wza->cancelShipment($shipment->id));

Other resources

Allegro::billing()->entries(['occurredAt.gte' => now()->subMonth(), 'type.id' => ['SUC']]);
Allegro::messaging()->threads(['read' => false]);
Allegro::messaging()->reply($threadId, 'Your parcel ships today.');
Allegro::messaging()->send('buyer-login', 'Thank you!', $orderId, [Allegro::messaging()->uploadAttachment('invoice.pdf', $pdf)]);
Allegro::customerReturns()->list(['status' => 'DELIVERED']);       // beta media type
Allegro::customerReturns()->rejectRefund($returnId, 'REFUND_REJECTED', 'Item was used');
Allegro::catalog()->matchingCategories('wool socks');
Allegro::catalog()->searchProducts('5901234123457', 'GTIN');
Allegro::catalog()->parameters('257931');

// Any other endpoint
Allegro::request('GET', '/sale/shipping-rates');
Allegro::request('PUT', "/sale/offer-modification-commands/{$uuid}", body: [...]);
Allegro::send('GET', '/sale/offers', ['limit' => 1])->header('Trace-Id');   // raw Response

Errors and rate limits

Situation Exception Useful methods
HTTP 400/422 validation ApiException errors() (code, message, details, path, userMessage), userMessage(), isValidationError()
HTTP 401 after one refresh and retry ApiException httpStatus(), OAuth error mapped to errors()[0]['code']
HTTP 404 ApiException isNotFound()
HTTP 409 (stale revision, reused commandId) ApiException isConflict()
HTTP 429 (9000 requests/min per Client ID, per-resource limits) ApiException isRateLimited(), retryAfter()
Connection failure ApiException getPrevious()
Failed WzA command, operation still running ApiException errors()
Missing token, revoked refresh token, denied device flow AuthenticationException requiresReauthorization(), error()
Missing configuration InvalidConfiguration

Every exception extends AllegroException. traceId() (and lastTraceId() on the client) returns the Trace-Id header that Allegro support asks for; set ALLEGRO_LOG_CHANNEL to log failed requests with it. The client never repeats a request automatically, except once after HTTP 401 (a rejected request was not processed). Back off on 429: exceeding the global limit blocks the Client ID for a minute.

Development

composer install
composer ci   # pint --test, phpstan (level 8), pest

Author

Created and maintained by Kamil Ćwiertnia, beziworld.eu.

Need an Allegro or marketplace integration? Get in touch through beziworld.eu.

License

MIT. See LICENSE.md. This package is not affiliated with, endorsed or sponsored by Allegro.pl sp. z o.o.