Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Sendcloud Php Client Laravel Package

picqer/sendcloud-php-client

A PHP client for the SendCloud API that helps you create shipments, generate labels, manage parcels and returns, and integrate SendCloud shipping into PHP or Laravel applications.

Deep Wiki
Context7

Getting Started

picqer/sendcloud-php-client is a thin, framework-agnostic Guzzle wrapper around the SendCloud API v2. There is no service provider, no config file, no facade — you own all the wiring. That's the first thing to internalise.

Install and pull credentials from the SendCloud panel (Settings → Integrations → API), not the "API key" shown on the dashboard (that's a different thing):

composer require picqer/sendcloud-php-client
// config/services.php
'sendcloud' => [
    'public_key' => env('SENDCLOUD_PUBLIC_KEY'),
    'secret_key' => env('SENDCLOUD_SECRET_KEY'),
],
// app/Providers/AppServiceProvider.php — register()
$this->app->singleton(SendCloudClient::class, fn () => new SendCloudClient(
    config('services.sendcloud.public_key'),
    config('services.sendcloud.secret_key'),
));

The client is stateless per request, so a singleton is safe and saves Guzzle re-instantiation.

Where to look first: open src/SendCloudClient.php and src/Models/ in vendor/. The client's public methods are your entire API surface; the models are plain public-property bags that mirror the JSON payloads. There is no documentation worth reading beyond those two directories — five minutes there answers 90% of questions.

First use case — buy a label for an order:

use Picqer\SendCloud\SendCloudClient;
use Picqer\SendCloud\Models\Parcel;

$client = app(SendCloudClient::class);

// 1. Discover which shipping methods are enabled on the account
$methods = $client->getShippingMethods();

// 2. Build the parcel from your order
$parcel = new Parcel();
$parcel->name         = $order->customer_name;
$parcel->company_name = $order->company_name;
$parcel->address      = $order->street;
$parcel->house_number = $order->house_number;
$parcel->postal_code  = $order->postal_code;
$parcel->city         = $order->city;
$parcel->country      = strtoupper($order->country_code); // ISO alpha-2
$parcel->email        = $order->email;
$parcel->telephone    = $order->phone;
$parcel->weight       = '1.250';                          // kg, string, dot decimal
$parcel->request_label = true;
$parcel->shipment      = ['id' => $shippingMethodId];

$created = $client->createParcel($parcel);   // returns a hydrated Parcel

$created->tracking_number and the label URL(s) are now available — persist them before the response object goes out of scope.

Implementation Patterns

Wrap the client in one domain service. Don't let SendCloudClient leak into controllers or jobs. A single App\Shipping\SendCloudShipmentService gives you one place to add logging, retries, and a test seam.

final class SendCloudShipmentService
{
    public function __construct(private SendCloudClient $client) {}

    public function buyLabel(Order $order, int $shippingMethodId): Shipment
    {
        $parcel = $this->mapOrderToParcel($order, $shippingMethodId);
        $created = $this->client->createParcel($parcel);

        return Shipment::create([
            'order_id'        => $order->id,
            'sendcloud_id'    => $created->id,
            'tracking_number' => $created->tracking_number,
            'label_path'      => $this->downloadLabel($created), // see below
            'status'          => 'announced',
        ]);
    }
}

Always queue label creation. createParcel is a synchronous HTTPS round-trip to a third party; it will occasionally take 2–3 seconds and will occasionally 500. A job with $tries = 3, backoff = [10, 60, 300] and a failed() handler that flags the order for manual review is the difference between a calm Monday and a bad one. Guard idempotency by checking for an existing Shipment row before calling out — the SendCloud API has no idempotency key, so a retried job with a partially-succeeded request can double-buy a label.

Download labels immediately; treat the URL as ephemeral. The label URLs SendCloud returns are time-limited. Stream them into your own storage (Storage::disk('s3')->writeStream(...)) inside the same job. If you store only the URL, your "print label" button in the admin will 403 within a day or two.

Discover shipping methods dynamically, never hardcode IDs. Method IDs are per-account and change when someone toggles a carrier in the SendCloud panel. Cache getShippingMethods() for ~1 hour with a php artisan sendcloud:refresh-methods command for when ops changes something at 4pm.

$methods = Cache::remember('sendcloud.methods', now()->addHour(),
    fn () => collect($this->client->getShippingMethods())->keyBy('id')
);

Pick the right sender with integrations. If you ship from multiple warehouses or run multiple brands, getIntegrations() maps to the sender addresses configured in SendCloud. Resolve the integration per order and set it on the parcel — otherwise everything ships from the default address and customs paperwork is wrong.

Handle tracking status via SendCloud webhooks, not polling. Configure a webhook URL in the SendCloud panel pointing at a Laravel route. Webhooks are unsigned, so put a long random token in the path (/webhooks/sendcloud/{token}) and validate it with hash_equals, plus an IP allowlist if you can get a stable one. Update status idempotently — webhooks are at-least-once and arrive out of order:

Shipment::where('sendcloud_id', $payload['parcel']['id'])
    ->whereIn('status', $allowedPreviousStatuses)   // guards against reordering
    ->update(['status' => $newStatus, 'status_updated_at' => now()]);

Keep a status_events table of every webhook payload verbatim. When a customer says "it says delivered but I never got it", that audit trail is the only thing that saves you.

Test with a fake, not with HTTP. The client has no injection hook for a mock handler, so define a narrow interface (ShipmentGateway) that your service depends on, implement it with SendCloudClient, and bind a FakeShipmentGateway returning fixture Parcel objects in tests. This also makes it trivial to swap carriers later.

Gotchas and Tips

  • SendCloudApiException is the only exception type you'll see. Catch it explicitly, and log both the exception message and the request payload — SendCloud's validation errors come back as nested arrays and the client surfaces them as a single string, which is unreadable unless you log the parcel you sent alongside it.
  • Models are loose bags of public properties. Set a field that doesn't exist and PHP 8.2+ will happily create a dynamic property; the API then silently ignores it. Cross-check every field name against src/Models/Parcel.php the first time you map an order. Consider mapping through an explicit array → fill() if the model supports it, or just write a single mapOrderToParcel() and unit-test it against a fixture.
  • request_label = false is a feature, not a failure. It creates an "announced" parcel with no label and no cost — useful for pre-registering shipments, or for a two-step flow where a picker confirms the box before you buy. Call the label step separately once dimensions/weight are final.
  • Weight and dimensions are strings with a dot decimal separator. Passing a float 1.25 works by accident; passing "1,25" produces a confusing API error. Normalise at the boundary.
  • country must be uppercase ISO 3166-1 alpha-2. nl will be rejected or, worse, treated as a different zone.
  • Pagination is the sharp edge. getParcels() maps to a paginated endpoint; if your reconciliation job silently only reads page 1, your nightly sync will quietly miss parcels. Check whether the client exposes per_page/page parameters — if not, subclass the client or drop to Guzzle directly for that endpoint rather than assuming you got everything.
  • No sandbox. There's no mock server, and test labels cost real money on some carriers. Develop against a cheap domestic method with request_label = false where possible, and keep a dedicated test account.
  • 429 and 5xx handling is on you. Add a Guzzle RetryMiddleware (or a retry() helper) with exponential backoff for 429/503. Bulk-buying 200 labels in a loop without throttling will get you rate-limited mid-run, leaving half your orders shipped and half not.
  • The client is deliberately incomplete. Addresses, return portals, and some newer v2 endpoints may not have wrapper methods. Don't fork the package for one endpoint — inject a Guzzle client configured with the same basic-auth credentials and hit the endpoint directly in your service. Extension point: subclass SendCloudClient, add the method, bind the subclass in the container.
  • The secret key is a secret. It grants full account access including label purchases. Never log it, never put it in a queued job payload, and rotate it in the SendCloud panel if it ever lands in a Sentry breadcrumb.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle