shippo/shippo-php
Shippo’s PHP library for Laravel and PHP applications, offering a simple client for the Shippo shipping API. Create labels, compare carrier rates, validate addresses, track packages, and manage returns with multi-carrier support.
Install and configure
composer require shippo/shippo-php
There is no service provider, no facade, and no config file — the library is a classmap-autoloaded set of Shippo_* classes around a static client. Wire it up yourself:
// config/services.php
'shippo' => [
'key' => env('SHIPPO_API_KEY'), // shippo_live_... / shippo_test_...
'version' => env('SHIPPO_API_VERSION'), // optional, pins Shippo-Version header
],
// app/Providers/AppServiceProvider.php boot()
if (config('services.shippo.key')) {
\Shippo::setApiKey(config('services.shippo.key'));
config('services.shippo.version') && \Shippo::setApiVersion(config('services.shippo.version'));
}
Start in test mode (shippo_test_...). Test labels are free, return a real tracking number, and use the carrier sandboxes (USPS in particular behaves differently in test — see Gotchas).
Where to look first
lib/Shippo/ in the repo — the whole API surface is here; Shipment.php, Transaction.php, and Address.php cover 90% of real usage.Shippo_Error subclasses — they will shape your error handling more than anything else.First use case: quote rates at checkout
$shipment = \Shippo_Shipment::create([
'address_from' => $fromAddressId, // or an inline array
'address_to' => $toAddressId,
'parcels' => [$parcelId],
'async' => false, // critical — see Gotchas
]);
foreach ($shipment->rates as $rate) {
// $rate->amount (string!), $rate->currency, $rate->provider,
// $rate->servicelevel_name, $rate->object_id <- persist this
}
Then, when the order is packed:
$transaction = \Shippo_Transaction::create([
'rate' => $rateId,
'label_file_type' => 'PDF',
'async' => false,
]);
// check $transaction->status === 'SUCCESS' before trusting it
Put an adapter in front of the static client. The library's static Shippo::setApiKey() and Shippo_Shipment::create() calls are effectively unmockable (it uses raw cURL, so Http::fake() will not intercept them). Bind a small interface and fake that in tests:
interface ShippingGateway {
public function rates(Address $from, Address $to, Parcel $p): array;
public function buyLabel(string $rateId): Label;
public function track(string $carrier, string $number): TrackingState;
}
Keep the adapter thin: normalize Shippo_Object graphs into your own DTOs (money as integer cents, dates as CarbonImmutable) so the rest of the app never touches magic properties.
Persist the ID chain. Every step produces an ID you need later: address->object_id, parcel->object_id, shipment->object_id, rate->object_id, transaction->object_id. Store them all on your orders/shipments table. Rates and shipments are retrievable later via Shippo_Shipment::retrieve($id) and Shippo_Rate::retrieve($id) — invaluable when debugging a customer complaint in production.
Buy labels from a queued job, never inline in a controller.
final class PurchaseShippingLabel implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public $tries = 3;
public $backoff = [5, 30, 120];
public $timeout = 60;
public function middleware(): array { return [new WithoutOverlapping($this->orderId)]; }
}
Label purchase is a real money movement and is not idempotent — double-dispatch means double charge and a second label. Use WithoutOverlapping plus a DB guard (UPDATE orders SET label_status='buying' WHERE id=? AND label_status='pending' and abort if 0 rows affected).
Model your state machine, not Shippo's. Shippo statuses (QUEUED → SUCCESS/ERROR) overlap awkwardly with your fulfillment flow. Map to pending → purchasing → purchased → voided and record Shippo's raw payload in a JSON column for forensics.
Re-quote at purchase time. Rate IDs remain valid for a while but the price can drift, and a stale rate ID makes Transaction::create fail. Cache the quote for the checkout page only (minutes), re-request rates when you actually buy.
Webhooks for tracking. Register once with Shippo_Webhook::create(['url' => ..., 'event' => 'track_updated', 'active' => true]), then handle in an API route (routes/api.php, CSRF-free by default) and push to a job. Because this library version has no HMAC verification helper, don't trust the payload: take data.tracking_number + data.carrier and re-fetch with Shippo_Track::get($carrier, $number) before mutating order state. Idempotency on tracking_number + status_date is mandatory — Shippo retries deliveries.
Multi-tenant / Octane. Never rely on the boot-time setApiKey(). In a service provider under Octane workers, or when each tenant has its own Shippo account, call \Shippo::setApiKey($tenant->shippo_key) at the top of every adapter method. It's a global static and leaks across requests otherwise.
async defaults to true. The most common beginner bug: Shippo_Shipment::create([...]) returns a shipment with an empty rates array and status: "QUEUED" unless you pass 'async' => false, or poll Shippo_Shipment::retrieve($id) until status === 'SUCCESS'. Same for transactions — an async transaction returns QUEUED and you must poll before printing the label.
Failures arrive as HTTP 200. A declined label (bad address, uncorrectable carrier error) comes back as a normal response with $transaction->status === 'ERROR' and a messages array. Only network/auth/validation problems throw. Always assert status === 'SUCCESS' and that label_url is non-empty.
Catch specific errors, in the right order. The hierarchy is Shippo_Error → Shippo_ApiError → Shippo_InvalidRequestError / Shippo_AuthenticationError / Shippo_RateLimitError, plus Shippo_ApiConnectionError which is not under Shippo_ApiError. A catch (\Shippo_Error $e) at the top swallows everything; a catch (\Exception $e) before it makes the specific handlers dead code. Retry only Shippo_ApiConnectionError and Shippo_RateLimitError; never retry Shippo_InvalidRequestError.
Money is a string. $rate->amount is "5.82", not a float, and $rate->currency is "USD". Convert deliberately ((int) round((float) $amount * 100)) and never do float arithmetic on it directly.
Units are mandatory. Every parcel needs weight (as a string) with mass_unit in g|kg|lb|oz, and distance_unit in cm|in|mm|ft|yd. Omit them and you get confusing carrier-side errors hours later. Pick oz/in or g/cm and enforce it in your DTO constructor.
Address validation is advisory. Shippo_Address::create([... 'validate' => true]) returns validation_results.is_valid — it never throws on a bad address, and a "valid" address can still fail at label time. Surface suggestions to the customer, but keep a manual-override path.
Rate lists are noisy. One shipment returns every service from every connected carrier account. Filter server-side by provider/servicelevel_token and sort by amount before sending to the browser; the raw payload is often 10–50KB.
Refunds have a window and a status. Shippo_Refund::create(['transaction' => $id]) returns status: PENDING — refunds are asynchronous and carriers (notably USPS) only allow them for a limited period after purchase. Persist the refund ID and reconcile later; don't tell the customer "refunded" on PENDING.
Objects are magic. Shippo_Object uses __get/ArrayAccess. Cast to array with a json_decode(json_encode($obj), true) round-trip before storing; toArray() implementations vary across versions. Note nested collection members are objects, not arrays — foreach works, json_encode works, array_map with typed callbacks does not.
No retries, no timeouts, no Guzzle. Timeouts come from cURL defaults and there's no hook to tune them. Wrap calls in your own retry with exponential backoff, and keep a circuit breaker so a Shippo outage doesn't stall checkout — degrade to "we'll email you a rate."
Version pinning matters. Last release was 2022-01-31 and the library sends a fixed Shippo-Version header. Newer Shippo features (v2 API, embedded shipping elements, some carrier accounts) simply aren't reachable through these classes. Pin the Composer version, and if you need new endpoints, the pragmatic escape hatch is a hand-rolled Guzzle client against https://api.goshippo.com rather than waiting for an upstream release.
Test mode lies about some things. USPS test labels and tracking events are simulated; FedEx/UPS sandboxes differ in which services are available. Validate your mapping logic in test mode, but do one real low-value live shipment before trusting production rate selection.
How can I help you explore Laravel packages today?