kyon147/laravel-shopify
A maintained full-featured Laravel package for Shopify app development, with OAuth, webhooks, billing, and support for expiring offline access tokens. Works with Laravel 8+ and installs via Composer.
This is the maintained fork of the (now deprecated) osiset/laravel-shopify. It is not a thin API wrapper — it ships the whole OAuth install flow, webhook registration, billing, and the admin routes an embedded Shopify app needs. Budget an afternoon for install, not five minutes.
Minimum viable install:
composer require kyon147/laravel-shopify
php artisan vendor:publish --tag=shopify-config
php artisan vendor:publish --tag=shopify-migrations # confirm the exact tag in the wiki
php artisan migrate
Then set at least these in .env (names mirror the published config/shopify-app.php, which is the file you should open first):
SHOPIFY_API_KEY / SHOPIFY_API_SECRET — from the Partners dashboard appSHOPIFY_API_SCOPES — comma-separated; request the minimum, changes force re-authSHOPIFY_API_VERSION — pin it explicitly, never rely on a defaultSHOPIFY_API_REDIRECT — must match the Partners dashboard "App URL"/redirect exactlyAPP_URL — https, publicly reachable (ngrok/cloudflared locally)Where to look first, in order:
config/shopify-app.php — this file is the documentation. Routes, middleware, webhooks, billing plans, and the user_model binding all live here.php artisan route:list | grep -i shopify — faster than reading config to see what got registered.First use case — prove the loop end-to-end: install the app on a dev store, land on the package's home route, then in a controller do:
$shop = ShopifyApp::shop();
$response = $shop->api()->graph('{ shop { name myshopifyDomain currencyCode } }');
If that returns data, OAuth, token storage, HMAC verification, and the API client are all wired. Everything else is incremental.
If you are building a new public app, decide on expiring offline tokens now, before you have customers:
SHOPIFY_EXPIRING_OFFLINE_TOKENS=true
Shopify requires expiring offline tokens for public apps created on/after 2026-04-01. Retrofitting later is a one-way migration per shop.
Treat the shops table as your tenant table. By default it doubles as config('shopify-app.user_model') — it holds shop domain, token, plan, and the usual user-ish columns. For per-user (multi-tenant) apps, put the ShopModel trait on your own User model and point user_model at it, so credentials hang off your existing auth rather than a parallel table.
Two API surfaces, and you should prefer one. apiHelper() builds the client; $shop->api() is the shortcut:
// GraphQL — prefer this
$shop->api()->graph($query, $variables);
// REST — legacy Admin API, being sunset; use for one-off endpoints only
$shop->api()->rest('GET', '/admin/api/2025-01/orders.json', ['limit' => 50]);
REST calls need the version embedded in the path and are not automatically kept in sync with SHOPIFY_API_VERSION. GraphQL calls are. Route new work through graph().
Never pass an Eloquent model into a queued job and trust it. Pass $shop->id (or the domain), then re-resolve in handle():
public function handle(): void
{
$shop = Shop::findOrFail($this->shopId);
$shop->api()->graph($mutation, ['input' => [...]]);
}
Webhooks: declare in config, handle in jobs. Map topics to job classes that extend the package's WebhookJob:
'api_webhooks' => [
'orders/create' => \App\Jobs\OrdersCreate::class,
'app/uninstalled' => \App\Jobs\AppUninstalled::class,
],
Register the webhook route behind the package's webhook-verification middleware, keep the handler fast (verify → persist raw payload → dispatch), and make handlers idempotent — Shopify retries and can deliver out of order. If you change api_webhooks after install, the store won't know about it until you re-register; the usual fix is re-running the install/auth flow on a dev store.
Gate premium features with billing in config, not in controllers. Define plans under api_billing_*, put the package's Billable middleware on your premium route group, then check $shop->plan in views. This keeps the charge/redirect dance out of your own code and survives Shopify's billing-flow changes.
Protect your routes deliberately. Two different worlds:
Mixing the two is the most common source of redirect loops. Pick one per route group and stick to it.
Use events for install lifecycle. The package fires authenticated/installed/uninstalled events; hook listeners rather than stuffing logic into routes. Uninstall in particular must clear local state — the token is dead the moment the webhook arrives.
In tests, swap the shop's API client for a fake rather than hitting Shopify. Resolve shops by ID in your code and the seams stay clean.
APP_KEY is now load-bearing. Offline refresh tokens are stored via Laravel's encrypter. Rotate APP_KEY carelessly and every shop's refresh token becomes undecryptable. Treat key rotation as a migration project, not a .env edit.
Expiring-token migration is one-way. When you exchange a legacy non-expiring offline token for an expiring one, Shopify revokes the old token. Test on a throwaway dev store first. The passive auto-migrate path (SHOPIFY_AUTO_MIGRATE_LEGACY, default on) runs on the first apiHelper() call and fails open — it logs and proceeds with the legacy token, so a silent log line is your only signal. Watch logs during rollout, or use the explicit action MigrateShopToExpiringOfflineAccessToken and inspect its migrated/skipped/reason/error keys.
Long-running jobs hold stale tokens. The API client is cached when first built. A job that loops for hours keeps the expired token even after refresh logic would have fired. Either enable SHOPIFY_REFRESH_OFFLINE_TOKEN_BEFORE_API_CALL=true (re-checks before each api()/apiHelper()) or do it manually per iteration:
if (! $shop->offlineAccessTokenIsFresh()) {
$shop->refreshOfflineAccessTokenIfNeeded();
}
$shop->api()->graph('...');
$shop->resetApiClient() clears the cached client if you need a forced rebuild.
Merge your casts, don't overwrite them. If your User/Shop model declares $casts, the ShopModel trait needs datetime casts for the two *_expires_at columns. The package uses mergeCasts during initializeShopModel — don't defeat it with a hard-coded array that predates the trait.
Serverless (Vapor, Lambda): use the queue commands, not the loops. shopify-app:migrate-expiring-offline-tokens and shopify-app:refresh-expiring-offline-tokens dispatch jobs rather than doing HTTP inline. Both support --dry-run, --shop=, --queue=, --connection= — point them at a dedicated worker via SHOPIFY_MIGRATE_OFFLINE_TOKENS_JOB_QUEUE etc. Note the package does not schedule anything; add to routes/console.php:
Schedule::command('shopify-app:refresh-expiring-offline-tokens')->daily();
Refresh tokens lapse at ~90 days of inactivity, so dormant shops will silently die without this.
The API route now throws if opened in a browser (v27.1.0). Curling it without proper headers will look like a bug and isn't.
The underlying HTTP client is the weakest link. OAuth/token exchange has been decoupled from gnikyt/basic-shopify-api, but REST/Graph traffic still routes through it. If you need long-term maintenance guarantees, plan to fork or replace that layer — the README says as much outright.
Local dev checklist before blaming the package: https + public URL, redirect URI byte-identical to the dashboard, correct API version, scopes that include what you're calling, and a freshly-installed shop after any webhook/scope config change.
Version pinning matters. v18 dropped PHP 7, v26 dropped older PHP/Laravel, v27 changed token semantics. Read the upgrade guide in the wiki before a major bump — the releases are small in number but large in scope.
How can I help you explore Laravel packages today?