shopify/shopify-api
Official Shopify API library for PHP. Build Shopify apps and integrations with REST and GraphQL Admin API support, OAuth, webhooks, session storage, and typed request/response helpers for modern PHP applications.
Install with Composer (the package name is shopify/shopify-api, not the repo name):
composer require shopify/shopify-api
Read these classes first, in this order — they are the whole library:
Shopify\Context — global config (Context::initialize(...)), API version, embedded-app flag, default session storage.Shopify\Auth\Auth — begin() / callback() for the OAuth install flow.Shopify\Clients\Graphql — the client you will use 95% of the time.Shopify\Webhooks\Registry — addHandler() / process() for webhooks.Shopify\Auth\SessionStorage\* — FileSessionStorage, CookieSessionStorage, and the SessionStorageInterface you'll almost certainly implement yourself in Laravel.One-time setup in config/shopify.php + AppServiceProvider::boot():
use Shopify\Context;
Context::initialize(
apiKey: config('shopify.api_key'),
apiSecretKey: config('shopify.api_secret'),
scopes: config('shopify.scopes'), // array or comma string depending on version
shopifyHostName: config('shopify.app_url'),
apiVersion: config('shopify.api_version'), // e.g. '2025-01', pinned
isEmbeddedApp: true,
sessionStorage: app(\Shopify\Auth\SessionStorage\SessionStorageInterface::class),
);
First use case to get working end-to-end: the OAuth handshake, then a single GraphQL call.
// routes/web.php
Route::get('/auth', [ShopifyAuthController::class, 'begin']);
Route::get('/auth/callback', [ShopifyAuthController::class, 'callback']);
public function begin(Request $request)
{
$shop = $request->string('shop'); // validate against *.myshopify.com first
$response = Auth::begin($shop, route('shopify.callback'), /* isOnline */ false);
return redirect($response->getHeaderLine('Location'))
->withCookies($this->psrCookies($response)); // don't drop Set-Cookie headers
}
public function callback(Request $request)
{
$session = Auth::callback($this->toPsrRequest($request), new \GuzzleHttp\Psr7\Response());
// persist $session->getAccessToken() keyed by shop
$client = new \Shopify\Clients\Graphql($session);
$result = $client->query([
'query' => 'query { shop { name myshopifyDomain } }',
]);
return $result->getDecodedBody();
}
Once that works, the library stops being mysterious: everything else is "load a session, construct a client, run a query."
Bind config and session storage as singletons, but never store a Session globally. Context::initialize() is process-wide and only needs to run once (fine in a service provider). Sessions are per-shop and per-request, so pass them explicitly to clients:
$session = $this->sessions->loadByShop($shop); // your repo, cached
$client = new Graphql($session); // explicit — safe in queues, jobs, commands
This constructor-injection style is what makes the library usable in Laravel's queue workers, where there is no HTTP request for Context to inspect. Prefer new Graphql($session) over any request-context helper in jobs and commands.
Wrap the client in one domain service per resource. Don't sprinkle raw GraphQL strings across controllers. One class per resource (Orders, Products, Fulfillments) that exposes typed methods, builds variables, unwraps getDecodedBody(), and throws domain exceptions on userErrors. This is also your only realistic testing seam — the library has its own HTTP layer, so Http::fake() will not intercept it, but a mocked repository interface will.
final class ShopifyOrders
{
public function __construct(private Session $session) {}
public function find(string $gid): array
{
$res = (new Graphql($this->session))->query([
'query' => 'query($id: ID!) { order(id: $id) { id name totalPriceSet { shopMoney { amount currencyCode } } } }',
'variables' => ['id' => $gid],
]);
return $res->getDecodedBody()['data']['order'] ?? throw new ShopifyException('Order not found');
}
}
Middleware for embedded apps. A ResolveShopifySession middleware that (a) validates the shop param or App Bridge session token, (b) loads the offline session, (c) binds it into the container, and (d) aborts 401 when absent. Keep JWT/HMAC verification in the middleware, not in every controller.
Webhooks: register in a provider, process in one controller, dispatch to queue.
// AppServiceProvider::boot()
Registry::addHandler('PRODUCTS_UPDATE', function ($topic, $shop, $body, $webhookId, $apiVersion) {
SyncProductJob::dispatch($shop, json_decode($body, true), $webhookId);
});
Registry::addHandler('APP_UNINSTALLED', fn ($topic, $shop) => $this->forgetShop($shop));
public function webhook(Request $request)
{
Registry::process($request); // validates HMAC + topic, runs handlers
return response('', 200);
}
Always return 200 fast; Shopify retries aggressively on non-2xx. Use X-Shopify-Webhook-Id for idempotency at the job level.
Pagination. For small sets, loop pageInfo { hasNextPage endCursor }. For anything bulk (catalog sync, order exports), use bulkOperationRunQuery + poll the operation + download the JSONL from url — it is dramatically faster and avoids cost-limit whack-a-mole.
Billing is just GraphQL: read currentAppInstallation { activeSubscriptions { name status } }, and create plans with appSubscriptionCreate. Keep the plan definitions in config so test and prod shops can differ.
composer.json, and put a calendar reminder to evaluate the successor path the README points to before your pinned API version is sunset (Shopify supports versions ~12 months).Context::initialize. Nothing warns you when it's retired — requests just start failing. Make it a config value with a test asserting it matches a supported version.Rest client because Shopify moved the REST Admin API to legacy status. If your codebase is on an older version and uses Rest, plan the GraphQL migration before you upgrade a major — this is the single largest upgrade cost.Auth::begin(), Auth::callback(), and session loading take PSR-7 Request/Response objects, not Laravel's. Convert with GuzzleHttp\Psr7\ServerRequest::fromGlobals() (or build manually) and, critically, copy Set-Cookie headers from the returned PSR-7 response onto the Laravel response. Miss this and OAuth "works" but the nonce/cookie verification fails randomly.FileSessionStorage will bite you in production. Default storage writes to a temp dir — broken on read-only filesystems, broken across multiple app servers, and wiped on redeploy. Implement SessionStorageInterface against the cache or a shopify_sessions table (loadSession, storeSession, deleteSession). This is the standard extension point and takes ~30 lines.VerifyCsrfToken, TrimStrings, and ConvertEmptyStringsToNull on the webhook route will break signature verification (or mutate the raw body before Registry::process sees it). Exclude the webhook route from CSRF and read the raw body untouched. Symptom: "Invalid webhook HMAC" for payloads that look fine in logs.GraphqlQueryException; GraphQL-level errors come back in the response body via getErrors(). Code that only catches exceptions will silently treat failed mutations as successes. Always check getErrors() and userErrors separately.extensions.cost.throttleStatus (notably currentlyAvailable). Batch requests into single queries with aliases/fragments, request only fields you use, and implement exponential backoff on THROTTLED. Don't blind-retry a 200-response that contains throttling errors.shop parameter before any API call — regex ^[a-z0-9][a-z0-9-]*\.myshopify\.com$. It's user input that ends up in your session store and URLs.X-Shopify-Access-Token or full webhook bodies (PII). Redact at the HTTP/log layer, and remember the library will happily include tokens in exception messages on a bad response.ACCESS_DENIED errors.How can I help you explore Laravel packages today?