jeffersongoncalves/laravel-shopify
A Laravel package for integrating Shopify stores into your application, with helpers for OAuth authentication, API requests, and webhook handling so you can build and manage commerce features more easily.
Install the package and wire up credentials before touching any resource class:
composer require <vendor>/<package>
php artisan vendor:publish --tag=shopify-config # verify the actual tag
Set the shop and an Admin API access token in .env (names depend on the published config — read config/shopify.php first). The shop value is forgiving: a bare handle (my-store), a my-store.myshopify.com domain, or a full admin URL all resolve, so you can paste whatever the merchant gave you.
Verify the connection with the cheapest possible call, in php artisan tinker or a temporary route:
shop(); // returns the store record — proves domain + token are valid
Where to look first:
Shopify facade — confirms how the client is bound and whether it is a singleton (relevant for multi-store later).ShopifyException class — this is your single error surface for every resource.products()), since all the others follow the same shape.First real use case: a scheduled command that pulls products into your database, or a webhook controller that receives orders/create and queues a job. Both exercise auth, pagination, and error handling in one go.
Read-through resources instead of a local mirror. For admin dashboards and support tooling, call products()->list(), orders()->get($id), customers()->search($query) directly and cache the results briefly. Only persist Shopify data locally when you need joins, reporting, or resilience against rate limits.
Resource methods are thin wrappers — compose them in your own service. Wrap the package in something like App\Services\Shopify\CatalogService so controllers and jobs never touch the facade. That gives you one place to add caching, retries, and logging, and one place to swap in a fake during tests.
class CatalogService
{
public function activeProducts(int $limit = 50): array
{
return Cache::remember("shopify.products.{$limit}", 300, fn () =>
products()->list(['status' => 'active', 'limit' => $limit])
);
}
}
Sync jobs should page, not count. Use the cursor/since_id style pagination the client returns rather than offset paging, and advance a stored watermark (updated_at_min or last ID) so re-runs are incremental. Run the job on a queue with $tries and backoff() set — writes especially should never run inline in a request.
Orders need explicit status filters. Order listing returns any status by default. If you only want open work, chain orders()->open(); for anything else, pass the status explicitly. Do the same for counts, otherwise your dashboard numbers will include cancelled and closed orders.
Use graphql() when the REST shape fights you. Nested data — variants with inventory levels, orders with line items and fulfillments in one shot — is far cheaper as one GraphQL query than several REST calls. The package converts Shopify's HTTP 200 errors payload into an exception, so try/catch (ShopifyException $e) behaves consistently across REST and GraphQL.
Reach for the escape hatch deliberately. Shopify::get()/post()/put()/delete() covers endpoints the resources don't wrap (metafields, discounts, inventory levels, themes). Keep those calls in a dedicated class with a comment naming the API version, so a future package release can absorb them cleanly.
Webhooks: register from code, verify in middleware. Create subscriptions with webhooks()->create([...]) during deploy or in an install flow rather than clicking them into the admin, so environments stay reproducible. In the receiving controller, verify the HMAC against the raw request body before parsing, then dispatch a job and return 200 immediately.
Route::post('/webhooks/shopify', WebhookController::class)
->middleware('shopify.webhook'); // your HMAC middleware
Handle errors by status code, not by message. ShopifyException carries both the API message and the status. Treat 404 as "gone, delete locally", 401/403 as "token or scope problem, alert", 429 as "back off and retry", 422 as "validation — surface to the user".
try {
products()->update($id, $payload);
} catch (ShopifyException $e) {
if ($e->getStatusCode() === 429) {
$this->release(60);
}
throw $e;
}
Multi-store. If the facade is a singleton bound to config, you likely cannot swap shops mid-request. Check the binding before designing a multi-tenant feature; if it isn't supported, resolve a fresh client per shop via the container or the escape hatch rather than mutating config at runtime.
errors array for bad queries and throttling. The package handles this for graphql(), but if you drop to Shopify::post() against /graphql.json you bypass that conversion entirely and must inspect the payload yourself.count() is not free. Count endpoints can be expensive and are cached by Shopify, so they may lag reality. For dashboards, prefer counting your own synced rows.page=2 works. Follow the returned cursor/Link header until exhausted, and always pass an explicit limit — defaults are small and silently truncate.ShopifyException message usually names the missing one.products()->delete(), customers()->delete(), webhooks()->delete() hit the real store. Gate destructive code paths behind a confirmation and point local/testing at a development store, never production.$request->all() re-encoded will not match. Use $request->getContent(). Also dedupe on X-Shopify-Webhook-Id — Shopify retries, and at-least-once delivery means your job will run twice.orders()->cancel() and creating fulfillments have preconditions (financial status, existing fulfillments). Check the order first; you'll otherwise collect 422s that read like generic validation errors.Http::fake()) or bind a stub of your own service class. Assert on your service's behaviour, and add one or two tests that assert a non-2xx response surfaces as ShopifyException with the right status code.Shopify::get() calls across controllers.How can I help you explore Laravel packages today?