automattic/woocommerce
PHP client for the WooCommerce REST API. Connect to a WooCommerce store and work with products, orders, customers, and other resources. It handles authentication and requests, giving PHP applications a simple way to integrate with WooCommerce.
This is not a Laravel package — no service provider, no facade, no config file, no Http::fake() support. It is a ~1k-line Guzzle wrapper with a magic-resource API ($client->products->get()). Plan to wire it yourself in about 10 lines.
1. Install and get credentials
composer require automattic/woocommerce
In the store admin: WooCommerce → Settings → Advanced → REST API → Add key, permissions Read/Write (Read is fine for read-only sync jobs). Copy the key/secret once; the secret is not shown again.
2. Register a configured singleton
// config/services.php
'woocommerce' => [
'url' => env('WOO_URL'), // https://shop.test — no trailing slash, no /wp-json
'consumer_key' => env('WOO_KEY'),
'consumer_secret' => env('WOO_SECRET'),
'version' => 'wc/v3',
'timeout' => 30,
'verify_ssl' => env('WOO_VERIFY_SSL', true),
],
// AppServiceProvider::register()
$this->app->singleton(\Automattic\WooCommerce\Client::class, fn () => new \Automattic\WooCommerce\Client(
config('services.woocommerce.url'),
config('services.woocommerce.consumer_key'),
config('services.woocommerce.consumer_secret'),
[
'version' => config('services.woocommerce.version'),
'timeout' => config('services.woocommerce.timeout'),
'verify_ssl' => (bool) config('services.woocommerce.verify_ssl'),
'user_agent' => 'Acme/1.0 (Laravel)',
]
));
3. First use case — smoke test in Tinker
$client = app(\Automattic\WooCommerce\Client::class);
$orders = $client->orders->get(['per_page' => 5, 'status' => 'processing']);
dump($orders);
If this returns data, your URL, key permissions and SSL are all correct — that's 90% of the setup failures eliminated in one call. If it 404s, the URL includes /wp-json (it shouldn't) or the store uses plain permalinks. If it 401s, the key lacks permission or the Authorization header is being stripped upstream (see Gotchas).
Where to look first: the constructor options array in Client, then HttpClient::createRequestUrl() — that single method explains every URL-shape bug you will hit.
Wrap it in a gateway, never inject the SDK directly. The Client builds its own Guzzle handler internally, so you cannot swap in a MockHandler or use Http::fake(). Testing therefore requires your own seam:
interface WooGateway {
public function orders(array $params = []): array;
public function updateProduct(int $id, array $data): array;
}
final class SdkWooGateway implements WooGateway {
public function __construct(private \Automattic\WooCommerce\Client $client) {}
public function orders(array $params = []): array {
return $this->normalize($this->client->orders->get($params));
}
// ...
}
Bind WooGateway in the container; bind a fake in tests/. This also gives you one place to add retries, logging and metrics.
Cursor-based incremental sync, queued. For orders, use modified_after (ISO 8601) and persist the cursor plus a small overlap window, because an order modified mid-page can be missed:
class SyncOrders implements ShouldQueue {
public function handle(WooGateway $woo, Cursor $cursor) {
$after = $cursor->value('orders.modified_after', now()->subYear()->toIso8601String());
$page = 1;
do {
$batch = $woo->orders([
'per_page' => 100,
'page' => $page,
'modified_after' => $after,
'orderby' => 'modified',
'order' => 'asc',
]);
Order::upsertMany($batch);
$page++;
} while (count($batch) === 100);
$cursor->set('orders.modified_after', now()->subMinutes(5)->toIso8601String());
}
}
Looping until a short page is the only pagination signal the resource helpers give you — the X-WP-TotalPages header sits on the underlying Response object, which the resource methods discard.
Batch writes instead of loops. Every write is a full round trip; products/batch and orders/batch accept up to 100 operations each. A 2,000-product price update becomes 20 requests instead of 2,000:
foreach (array_chunk($updates, 100) as $chunk) {
$client->post('products/batch', ['update' => $chunk]);
}
Same for products/{id}/variations/batch.
Treat webhooks as the primary path, polling as reconciliation. The library has no webhook support and never will — it is outbound only. Receive webhooks in a controller, verify, dispatch a job that re-fetches the object by ID via the SDK (never trust the payload body as authoritative):
$raw = $request->getContent();
$sig = $request->header('X-WC-Webhook-Signature');
abort_unless(hash_equals(base64_encode(hash_hmac('sha256', $raw, $secret, true)), $sig), 401);
SyncOrder::dispatch($request->json('id'))->onQueue('woo');
return response('', 200); // respond fast; WC retries aggressively on non-2xx
Cache aggressively on the read side. Catalog reads are the most common per-request call, and category/brand/product lookups change slowly. Cache::remember("woo.product.{$id}", 600, fn () => $client->get("products/{$id}")) removes the network from the hot path. Bust on webhook receipt.
Multi-store: don't use the singleton. If you serve several stores, register a factory keyed by store ID returning fresh Client instances, and store each store's credentials with an encrypted cast. A shared client leaks credentials across tenants in long-running queue workers.
query_string_auth is not optional on some hosts. Many Apache/FastCGI, WP Engine and Cloudflare setups strip the Authorization header. Symptom: 401 woocommerce_rest_cannot_view even though the key is valid. Set 'query_string_auth' => true — but know that the key/secret then travel in the query string and will appear in access logs, proxies and HttpClientException messages. Redact before logging.
verify_ssl => false is a local-only escape hatch. It exists for self-signed .test certs. A cURL error 60 in production is a real certificate problem — fix the chain, don't disable verification. Relatedly, follow_redirects is off by default; stores that 301 to www. will silently fail until you enable it or fix the base URL.
The base URL is https://shop.test, not https://shop.test/wp-json. The library appends /{wp_api_prefix}/{version}/. If WordPress lives in a subdirectory, the subfolder belongs in the URL: https://example.com/shop. If the store uses plain permalinks, wp-json doesn't exist at all and this library cannot reach it — you need pretty permalinks or a different transport.
version is a free-form path segment, not an enum. 'version' => 'wc/v3' is the modern form; legacy 'v3' is for pre-3.0 stores. Because it's just a string, you can also point it at 'my-plugin/v1' to hit custom namespaces — a genuinely useful extension point.
Everything non-2xx throws. That includes 401/404 validation errors and DNS/connection failures, which arrive as HttpClientException with code 0. So catch must branch on the code before reading the body:
try {
$client->put("products/{$id}", ['regular_price' => '19.99']);
} catch (HttpClientException $e) {
if ($e->getCode() === 0) { throw $e; } // network — retryable
$error = $e->getResponse()->getBody(); // ['code' => ..., 'message' => ..., 'data' => ['status' => 400]]
Log::warning('woo rejected write', ['id' => $id, 'error' => $error]);
}
No retries, no rate limiting, no backoff. You must implement it. Retry on 0, 429, 500, 502, 503, 504 with exponential backoff plus jitter; never retry 400/401/404. WooCommerce shares the host's PHP worker pool, so a runaway loop can take a shop down.
Order creation is not idempotent. There are no idempotency keys. A timeout after a successful POST /orders followed by a retry produces a duplicate order and a real customer complaint. Either don't retry creates, or stamp a unique meta_data value and query for it before retrying.
meta_data on PUT replaces the whole array. Send the full meta list, not a diff, or you'll wipe fields your other integrations depend on.
Responses are JSON-decoded object graphs (stdClass), not arrays. $order['billing'] fails; $order->billing->email works, and so does data_get($order, 'billing.email'). Normalize once at your gateway boundary (json_decode(json_encode($r), true)) so the rest of the app isn't fluent in two access styles.
Money is strings, and totals don't always add up. total, subtotal, price arrive as strings in store currency; line-item sums can disagree with the order total due to tax rounding and coupons. Never (float) your way through accounting — parse to integer minor units (or brick/money) and read price_decimals from the store settings.
per_page caps at 100, batch operations cap at 100 items, and some endpoints 400 on a page past the end (rest_post_invalid_page_number). Guard your loops against both infinite pagination and exceptions on the final empty page.
Clock skew breaks OAuth. Credentials sent over plain http:// fall back to OAuth 1.0a signing, which is timestamp-sensitive; oauth_timestamp_is_local (default false) uses your server's clock instead of UTC. Use HTTPS and this disappears — but if a legacy store forces HTTP, this is why signatures fail.
Dates are ISO 8601 and timezone-naive relative to the store. after / before / modified_after filter on the store's configured timezone, not config('app.timezone'). Convert deliberately, or your sync window will drift by hours twice a year.
Testing story is the real cost. No container bindings, no fakeable transport, no way to assert on outgoing requests. Budget for the gateway interface up front; retrofitting it after fifty call sites is painful.
How can I help you explore Laravel packages today?