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

Woocommerce Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

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.

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.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect