statamic-rad-pack/shopify
Statamic addon that connects your Shopify store, imports products as Statamic entries, and keeps catalog data in sync so you can build fast, editable storefronts with Laravel and Statamic.
statamic-rad-pack/shopify is a Statamic 5 addon (so: a Laravel package) that turns a Shopify catalogue into Statamic entries and exposes the cart/checkout flow to Antlers and Blade. You use it when the marketing site lives in Statamic but the commerce engine (payments, tax, fulfilment, inventory) stays in Shopify.
v8 note first: this release swaps the HTTP layer to the official shopify/shopify-app-php library. That library is GraphQL-first and session-aware, so treat v8 as a config-and-integration break, not a drop-in. Read config/shopify.php and the addon's README/upgrade notes before touching templates.
Minimal path:
composer require statamic-rad-pack/shopify
php please vendor:publish --tag=shopify-config
php please migrate # variants/carts/orders tables the addon ships
Then populate credentials (names may have shifted in v8 — always diff the freshly published config against your old one):
SHOPIFY_SHOP_DOMAIN=your-store.myshopify.com
SHOPIFY_ACCESS_TOKEN=shpat_xxx # custom app, Admin API scopes
SHOPIFY_API_VERSION=2025-07
php please shopify:import # confirm exact command name via `php please list`
Where to look first, in order:
config/shopify.php — collection handles, field mappings, API version, webhook settings, log channel.routes/ — cart/checkout/webhook endpoints; you will be posting to these from JS.src/Tags/ — the Antlers surface you'll actually template against.src/Jobs/, src/Listeners/ — where product import and webhook sync happen.First use case to get end-to-end before anything else: a product listing page rendering imported entries, a variant selector, an add-to-cart POST, and a redirect to Shopify's hosted checkout. If that loop works, the rest is polish.
Catalogue as entries. Imported products land in a normal Statamic collection, so everything you already know applies — Nav, search, Antlers, the REST/GraphQL content APIs, revisions, multi-site.
{{ collection:products limit="12" sort="title" }}
<a href="{{ url }}">
{{ if featured_image }}<img src="{{ featured_image }}" alt="{{ title }}">{{ /if }}
<h2>{{ title }}</h2>
<p>{{ price | format_number(2) }} {{ currency }}</p>
{{ if variants|length > 1 }}<span>{{ variants|length }} options</span>{{ /if }}
</a>
{{ /collection:products }}
Cart mutations over AJAX. The addon exposes cart routes; keep the JS thin and let the server hold state so Antlers and Blade agree.
async function addToCart(variantId, quantity = 1) {
const res = await fetch('/!/shopify/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ variant_id: variantId, quantity }),
});
return res.json(); // { cart, count, total }
}
Re-render a cart drawer from the JSON response rather than reloading, and let the checkout step be a plain redirect to the cart's checkout_url. Never build your own payment form — Shopify's hosted checkout is the compliant path.
Sync via webhooks, import as backfill. The import command is a one-off; webhooks (products/create|update|delete, inventory_levels/update) are the steady state. Point Shopify at your endpoint, keep it out of CSRF, and make the handler idempotent — Shopify retries, and out-of-order delivery is normal.
// routes/web.php
Route::post('shopify/webhooks', [ShopifyWebhookController::class, 'handle'])
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
Extension points worth knowing. Rather than editing vendor code: hook the import/sync events the addon fires, override the importer/importer job bindings in a service provider, or subclass the tags. Field mapping (Shopify product → Statamic fields, metafields → your fieldsets) belongs in config or a small mapper class you own.
Local development. Webhooks need a public URL — use ngrok/expose/Laravel Herd's share, and keep a SHOPIFY_WEBHOOK_SECRET-style value per environment. Also run the import against a development store, never production, on first setup.
Blade instead of Antlers is fine where you need typed access:
$products = \Statamic\Facades\Entry::query()
->where('collection', 'products')
->where('available', true)
->limit(12)->get();
v8's library swap is the big one. shopify/shopify-app-php is a different API shape from the previous client. Anything you wrote that reached into the addon's client directly (custom commands, bespoke imports, jobs calling REST endpoints, tests mocking the old client) will break. Republish the config and diff it, then grep your codebase for the old library's namespaces.
Session storage is now a first-class concept. The official library expects a SessionStorage implementation. If the addon defaults to a file-based store, that breaks on multi-server deployments, ephemeral containers, and some queue workers. Push it onto the database or cache driver, and confirm the CLI/queue context resolves the same shop session as the web request.
GraphQL cost limits ≠ REST call limits. REST's 2 req/s leaky bucket is gone; you're now spending points from a calculated bucket. Bulk catalogue imports that were fine on v7 can get throttled. For a large store, chunk the import (--chunk, or per-collection slices), queue it, and consider Shopify's bulkOperationRunQuery for the initial pull. Add backoff on 429/THROTTLED — do not just retry in a tight loop.
Webhook verification moved. The library verifies HMAC from the raw body. Don't let middleware normalise or re-encode the payload before it reaches the handler, don't json_decode then re-verify, and keep the endpoint outside CSRF. If verification silently fails you get 401s and a catalogue that quietly stops updating — assert webhook success in your logs, and check Shopify's webhook delivery health page.
API version pinning. Set an explicit SHOPIFY_API_VERSION and put a quarterly reminder in the calendar. Shopify ships a new version every three months and supports each for ~12 months; an unpinned or stale version is the most common cause of mysterious field-level errors after a quiet period.
Scopes and token type. Custom-app Admin API tokens are permanent; public/distributed apps use OAuth with expiring access tokens and, for 2026 compliance, refresh tokens. If you plan to distribute, design for token rotation now — the session storage work above is the same work.
Don't cache inventory. Stock levels change outside Statamic. Render availability from a short-TTL value or the live cart response; never bake it into a statically cached page. And exclude /cart, /checkout, and any cart partial from Statamic's static caching and from your CDN, or customers will get each other's carts.
Money is not a float. Prices arrive in minor units. Keep integers end-to-end and format only at the edge; format_number on a float derived from 19.99 * 100 will eventually show 1998.9999.
Debugging toolkit. Put the addon's log channel on daily, tail -f storage/logs/laravel.log, and reproduce with php please shopify:import --product=<id> style single-record runs rather than a full catalogue. When something looks wrong on the storefront, check the entry's stored Shopify ID and variant IDs before suspecting templates — mismatched IDs after a re-import are the usual culprit.
Multi-site. Shopify handles translations via its own Translations API; Statamic handles them via sites. Decide which is authoritative per field (usually: Shopify for product copy, Statamic for landing-page content) and write that down, or you'll fight drift forever.
How can I help you explore Laravel packages today?