slince/shopify-api-php
A PHP SDK for the Shopify Admin API that simplifies authentication and CRUD operations across products, orders, customers, and more. It handles pagination, rate limits, and raw API calls, giving PHP and Laravel developers a clean, object-oriented way to integrate Shopify stores.
composer require slince/shopify-api-php
You need a Shopify access token. In the Shopify admin, create a custom app (Settings → Apps and sales channels → Develop apps) with the scopes you need, install it, and copy the Admin API access token. For a public app you'd run OAuth yourself — this package is not an OAuth framework, it's an HTTP client for the Admin API.
use Shopify\Client;
$client = new Client('my-shop.myshopify.com', $_ENV['SHOPIFY_ACCESS_TOKEN']);
$products = $client->product->all(['limit' => 10]);
foreach ($products as $product) {
echo $product['id'], ' ', $product['title'], PHP_EOL;
}
The client exposes REST resources as magic properties ($client->product, $client->order, $client->customer, $client->webhook, $client->shop, etc.). Each resource gives you all(), get($id), create($payload), update($id, $payload), delete($id), plus resource-specific verbs (count(), webhook->send(), order->close()).
src/Client.php — constructor signature, options array, how the Guzzle client and headers are built, and the __get map from property name to resource class.src/Resource/ — one class per Shopify resource. This is the real API surface; method names and accepted params live here, not in the README.src/Exception/ — the exception hierarchy you'll catch in application code.tests/ — the fastest way to see real request/response shapes, including pagination and error cases.A nightly sync job is the canonical starter: pull changed products with updated_at_min, upsert into your own database, and record since_id/updated_at as a cursor. Do this before building write paths — read-only integration teaches you the pagination and rate-limit behavior with zero risk of corrupting merchant data.
Don't sprinkle new Client(...) around the codebase. The package client is thin and stateless per request; wrap it so you control configuration, retries, logging, and test doubles in one place.
namespace App\Services;
use Shopify\Client;
class ShopifyFactory
{
public function make(): Client
{
return new Client(
config('shopify.domain'),
config('shopify.token'),
[
'timeout' => 30,
// anything the package forwards to Guzzle
]
);
}
}
Bind it in Laravel's container ($this->app->singleton(Client::class, ...)) or resolve it per job — the client holds no per-request state that matters, so singleton is fine.
Treat each Shopify resource as a repository. Keep mapping logic (Shopify JSON → your Eloquent/Doctrine model) in a dedicated class so the resource call stays a one-liner:
$service = app(ShopifyFactory::class)->make();
foreach ($service->product->all(['limit' => 250, 'updated_at_min' => $cursor]) as $row) {
ProductImporter::upsert($row);
}
Shopify REST pagination is cursor-based (page_info from the Link header) and, for large collections, since_id-based. limit caps at 250. Two workable patterns:
since_id — increment your own ascending ID cursor. Simple, resilient, and easy to resume after a crash. Works for products, orders, customers.page_info — required for endpoints that don't support since_id and for filtered queries. Read page_info out of the response Link header and pass it on the next call.For a sync job, persist the cursor (ID or timestamp) in your database, not in memory, so restarts don't re-fetch from the beginning. Write the loop as "fetch a page → process → commit cursor → next page" so a mid-job failure costs you one page, not one run.
Shopify doesn't dedupe writes for you. Two habits that save pain:
id and admin_graphql_api_id immediately after create(), before doing anything else that can fail.available_adjustment, order cancel with notify_customer) rather than read-modify-write from your side, which races with merchant activity.update() takes the resource ID plus a payload; make sure the root key matches Shopify's expected envelope (['product' => [...]]), because the package passes the body through as-is.
Register webhooks programmatically so they're reproducible across environments:
$client->webhook->create([
'webhook' => [
'topic' => 'orders/create',
'address' => 'https://example.com/webhooks/shopify/orders',
'format' => 'json',
],
]);
On the receiving side, verify X-Shopify-Hmac-Sha256 against hash_hmac('sha256', $rawBody, $secret, true) before touching the payload, respond 200 fast, and hand the body to a queued job. Do not do database work inline — Shopify retries on timeout and you'll get duplicates.
The client is Guzzle-backed, so the cleanest test seam is a mock handler. Inject a Guzzle client with MockHandler into the package client (via the options array or by wrapping your factory in an interface), feed it canned Shopify JSON, and assert on your importer, not on the package. If the package doesn't expose injection cleanly in your version, put an interface in front of the client and mock that — you'll want the seam anyway.
Pin the API version explicitly. Shopify supports a rolling window of versions (~12 months) and silently changes behavior between them. Older releases of this package may default to a legacy version or omit the X-Shopify-Version header entirely. Check what Client sends and, if necessary, set the version via the options array or a custom header. If the package can't set it, that's a strong reason to put a thin HTTP layer of your own in front. This is the single biggest long-term risk with a low-maintenance client.
Rate limits are on you. Shopify REST allows roughly 2 requests/second sustained (leaky bucket of 40). This package does not implement a bucket or backoff. Wrap calls in a limiter and handle 429 with exponential backoff — the Retry-After header tells you how long. Without this, a 250-page sync will start failing partway through and produce hard-to-reproduce partial imports.
Exceptions come in two flavors. Guzzle/client-side failures (network, DNS, timeout) surface as Guzzle exceptions; API-level failures (4xx/5xx with a JSON error body) surface as package exceptions. Catch both, and log the response body — Shopify's error payload (errors as a string, array, or hash depending on the endpoint) is the only useful debugging signal. A 422 almost always means a missing required field or a duplicate handle/SKU.
IDs are strings in JSON. PHP will happily hand you a 64-bit-safe string that looks like an integer. Don't cast to int on 32-bit builds, and compare IDs as strings when matching against your local records.
Money and quantities are strings. Prices come back as decimal strings; quantities and weights may too. Never do float math on price — use bcmath or integer cents.
count() is a separate, rate-limit-expensive call. It's handy for progress bars but don't call it per page.
Pagination via page_info invalidates other filters. When you pass page_info, Shopify ignores limit, since_id, and most filter params — you must replay the exact original query. Mixing them silently returns wrong data.
Webhook verification: use the raw body. Framework middleware that parses JSON first will corrupt the HMAC check. Read file_get_contents('php://input') (or Laravel's $request->getContent()) before any decoding.
Don't rely on the README for method names. The package is small and the resource classes drift from the docs. When you get an "undefined method" or a 404, open src/Resource/TheResource.php — the method you want may exist under a different name, or not exist at all, in which case drop down to a raw request through the underlying Guzzle client.
Retrofitting is cheap; forking is cheaper than you think. At ~130 stars and infrequent releases, expect to eventually own a patch. Because the surface is thin, the realistic long-term options are: (a) keep using it and isolate behind your own adapter interface, (b) replace the transport with a few dozen lines of Guzzle against Shopify's REST or GraphQL Admin API, or (c) migrate to Shopify's official GraphQL-first direction. Option (a) buys you the most time for the least effort — do it on day one, not after the first breaking change.
Consider GraphQL for anything non-trivial. REST is deprecated in Shopify's roadmap for new features (bulk operations, metafields, fulfillment orders). If your integration is growing beyond products/orders CRUD, the effort of a GraphQL client may be lower than working around REST's per-endpoint quirks.
How can I help you explore Laravel packages today?