Run one Laravel app as a fleet of dedicated Shopify apps
Short answer: kyon147/laravel-shopify reads one api_key/api_secret pair out of config/shopify-app.php, so a single installation can only ever speak for one Shopify app. But it also already ships the hook that fixes this — config_api_callback, a closure the package calls to resolve api* config values per shop. deploteka/laravel-fleet is a correct implementation of that hook plus the two DeploTeka fleet endpoints. One composer require, one migration, one environment variable. Budget 10–30 minutes.
This is the Laravel version of what npx deploteka onboard does automatically for Remix and React Router apps. Laravel doesn't get a codemod, and for once that isn't a limitation: there is almost nothing to rewrite.
Why this integration is unusually small
Every credential read in kyon147/laravel-shopify funnels through one function, Osiset\ShopifyApp\Util::getShopifyConfig(). Inside it:
if (Str::startsWith($key, 'api')
&& Arr::exists($config, 'config_api_callback')
&& is_callable($config['config_api_callback'])) {
return call_user_func(Arr::get($config, 'config_api_callback'), $key, $shop);
}
return Arr::get($config, $key);
That hook has been in the package since osiset v17.1.1 and is present, unchanged, in every kyon147 tag through v27.1.0. And because everything reads credentials through getShopifyConfig(), implementing it once reaches every path that matters:
| What has to become per-shop | Where the package reads it | Covered by the hook? |
|---|---|---|
| OAuth begin/callback and all Admin API calls | Services/ApiHelper::make() → api_key, api_secret, api_version | yes |
| Webhook HMAC verification | Http/Middleware/AuthWebhook → api_secret, keyed on the X-Shopify-Shop-Domain header | yes |
| Session-token (JWT) verification | Objects/Values/SessionToken::verifySignature() and verifyValidity() | yes |
The App Bridge apiKey in your Blade view | your own view | one line, step 5 |
No middleware to insert. No controller to override. No request-scoped context to set up and tear down. Compare that to Rails, where ShopifyAPI::Context is a process-global singleton and the equivalent change means an around_action and a careful think about threads.
What still has to be true
Running one codebase as many dedicated Shopify apps means four things:
- OAuth runs against the requesting store's
client_id/secret. - Webhook HMAC is verified with the requesting store's secret — get this wrong and either every webhook fails, or worse, one tenant's signature validates against another's.
- App Bridge (embedded apps only) initialises with the requesting store's
client_id. - Each store's credentials live somewhere durable, and new ones can arrive as DeploTeka provisions stores.
The hook handles 1 and 2. The package handles 4 and gives you 3 in a line.
1. Install
composer require deploteka/laravel-fleet
The service provider is auto-discovered — nothing to register.
Not on Packagist yet. Until it is published, add the repository to your
composer.jsonfirst: ``json { "repositories": [ { "type": "vcs", "url": "https://github.com/fixelpixel/laravel-fleet" } ] }`thencomposer require deploteka/laravel-fleet:dev-main`. Ask DeploTeka support for access if the repository isn't reachable. This is the only step that changes once the package is public.
2. Migrate
php artisan vendor:publish --tag=fleet-config # optional
php artisan migrate
That creates fleet_stores — the local credential replica your app owns:
| Column | Notes |
|---|---|
shop | unique; the store's *.myshopify.com domain, lowercased |
client_id | that store's dedicated app |
secret | text, stored with Laravel's encrypted cast |
app_url | that store's application URL |
Two deliberate choices worth knowing. The secret is encrypted at rest with your APP_KEY, which DeploTeka does not have. And the column is text rather than string: the encrypted envelope is several times the length of the plaintext, and a varchar(255) truncates it silently on MySQL — which surfaces much later as an undecryptable secret and a fleet of 401s.
Reads never leave your app. DeploTeka pushes rows in; it never queries your database.
3. Set one environment variable
FLEET_REGISTER_TOKEN=<a long random string>
Record the same value on the app card in DeploTeka. It gates both fleet routes, the comparison is timing-safe, and an unset token means both routes reject everything — it fails closed, never open.
Treat it like a production secret: whoever holds it can overwrite a store's credentials, which means hijacking that store's app identity.
Leave `SHOPIFY_API_KEY` and `SHOPIFY_API_SECRET` exactly as they are. They are now the fallback for every shop without a dedicated app, which is what makes the migration gradual instead of a cutover.
That is the whole installation. The hook is bound, both routes are live, and every credential read is per-shop.
4. What you just got
`POST /api/fleet/register` — bearer-gated. DeploTeka pushes a freshly provisioned store's clientId, secret and appUrl; they are upserted into fleet_stores. contractVersion: 1, idempotent, so it is also the path a rotated secret arrives on.
`GET /api/fleet/installed?shop=` — bearer-gated, read-only, Cache-Control: no-store. Returns installed, clientId, secretFingerprint, appUrl and grantedScopes. Row facts come back even when installed is false, because DeploTeka checks them before the merchant installs.
Per-shop credentials everywhere, through the hook.
Neither route is in the web middleware group. That's not an oversight: DeploTeka is a server-to-server caller with no session and no CSRF token, and a web-grouped route would answer 419 to every registration.
Where installed comes from
The answer has to match what the Shopify package itself would consider installed, or your DeploTeka cabinet will report a store as live when the app can't actually call it. So the adapter reads kyon147's own storage rather than inventing a table:
- The shop record lives on your app's own table —
usersby default, or whatevershopify-app.table_names.shopssays. kyon147 ships no shops table; its migration adds columns to yours. nameholds the shop domain.passwordholds the Shopify access token — yes, really; it reuses Laravel's auth column, which is why the migration widens it to 100 characters.- An uninstall soft-deletes the row.
The predicate is upstream's own, from VerifyShopify::shopIsInstalled(): the row exists, password is non-empty, and the row is not trashed.
5. Embedded apps: the one line of code
If your Blade view hardcodes the App Bridge key, make it shop-aware:
-$apiKey = config('shopify-app.api_key');
+$apiKey = \Osiset\ShopifyApp\Util::getShopifyConfig('api_key', $shop);
Get this wrong and the backend authenticates correctly while the iframe initialises against the wrong app identity — a confusing failure to debug, because nothing in your logs looks broken.
Non-embedded and webhook-only apps skip this entirely.
Already using config_api_callback?
Yours is never overwritten. Compose instead:
// AppServiceProvider::boot()
$fleet = app(\Deploteka\LaravelFleet\ConfigApiCallback::class);
Config::set('shopify-app.config_api_callback', function (string $key, $shop = null) use ($fleet) {
if ($key === 'api_version') {
return '2026-04'; // your own override
}
return $fleet($key, $shop); // everything else, fleet-resolved
});
One warning if you'd rather write your own from scratch. Upstream routes every key beginning with the literal string api through the callback — api_version, api_scopes, api_grant_mode, api_redirect, api_deferrer and more, not just the two credential keys. A callback that answers api_key/api_secret and returns null for the rest doesn't partially work: it silently blanks the API version and the requested scopes, and the app fails in ways that look like Shopify's fault. The last line of any correct callback has to reproduce upstream's own fall-through, Arr::get($config, $key). ConfigApiCallback does; delegating to it is the safe route.
The four shapes of $shop
Worth knowing if you ever debug the hook, because upstream doesn't normalise it. Depending on the call site, the second argument arrives as:
- a
NullableShopDomainvalue object — fromAuthWebhookandSessionToken; - a plain string — from
ApiHelper::make(), which calls->toNative()first; - your Eloquent shop model — the
ShopModeltrait passes$this; - `null` — the documented default, and plenty of call sites have no shop yet.
The adapter handles all four by duck typing (isNull(), toNative(), getDomain(), ->name), so it isn't pinned to a version of those value objects and doesn't break if you swap your shop model. null, an unresolvable shop, or a shop with no fleet_stores row all fall back to your base app credentials.
Secret rotation
POST /api/fleet/register is an idempotent upsert, which makes it how a rotated secret arrives.
On Laravel there is nothing further to do. PHP is shared-nothing per request: the next request after a rotation reads the new row. The adapter memoises lookups within a request — the hook is called several times per request, and without it a single webhook would cost three or four decrypts — but that memo dies with the request and is written through on put anyway.
This is genuinely simpler than the Node side, where a long-lived process caches one Shopify app instance per client_id and a rotation (which keeps the client_id) needs an explicit invalidation hook. If you run Octane or resolve the store inside a long-lived worker, call forget() on it after a rotation; everyone else can ignore this section.
Verify before you point DeploTeka at it
1. Register refuses an unauthenticated call:
curl -i -X POST "$APP_URL/api/fleet/register" \
-H 'content-type: application/json' \
-d '{"contractVersion":1,"shop":"acme.myshopify.com","clientId":"acme-id","secret":"acme-secret","appUrl":"https://acme-dedic.example.com"}'
Expect 401.
2. Register succeeds with the bearer:
curl -s -X POST "$APP_URL/api/fleet/register" \
-H "authorization: Bearer $FLEET_REGISTER_TOKEN" -H 'content-type: application/json' \
-d '{"contractVersion":1,"shop":"acme.myshopify.com","clientId":"acme-id","secret":"acme-secret","appUrl":"https://acme-dedic.example.com"}'
Expect {"contractVersion":1,"ok":true,"shop":"acme.myshopify.com"}, and a row in fleet_stores whose secret column is not readable plaintext.
3. Installed reports the row before any install:
curl -s "$APP_URL/api/fleet/installed?shop=acme.myshopify.com" \
-H "authorization: Bearer $FLEET_REGISTER_TOKEN"
Expect 200 with installed:false, your clientId, your appUrl, and a 16-character secretFingerprint — the first 16 hex characters of sha256(secret). DeploTeka computes the same value independently; a mismatch is the earliest possible signal that the wrong secret got stored, and a much faster diagnosis than reading OAuth logs.
4. Two shops resolve to two apps — the actual feature. Register a second shop with different credentials, then, from php artisan tinker:
Osiset\ShopifyApp\Util::getShopifyConfig('api_key', 'acme.myshopify.com'); // acme-id
Osiset\ShopifyApp\Util::getShopifyConfig('api_key', 'bravo.myshopify.com'); // bravo-id
Osiset\ShopifyApp\Util::getShopifyConfig('api_key', 'nobody.myshopify.com'); // your SHOPIFY_API_KEY
Three different answers, the third being the base fallback. If all three are identical, the hook isn't bound — check that config('shopify-app.config_api_callback') is an instance of ConfigApiCallback and that nothing in your own service providers set it afterwards.
One honest difference from the Node runtimes
A Node Shopify app stores a session row per shop with the granted scope string on it, so grantedScopes is a straight read. kyon147 stores no per-shop granted scopes at all — the scopes it asks Shopify for live in shopify-app.api_scopes. So for an installed shop this package reports the scopes configured for that shop (resolved through the same shop-aware path, so a per-shop override is respected), and [] for one that isn't installed.
In practice the two agree, because an install is what granted those scopes. They can differ only in the window between changing api_scopes and merchants re-authorising. If your app does record granted scopes per shop, override InstallStateResolver and return the exact value.
How we know the PHP matches the contract
Every other DeploTeka runtime — Remix, React Router, legacy Remix, Express — imports the wire contract from one TypeScript module, so they physically cannot disagree with each other. PHP can't import TypeScript, so this package reimplements it, and a reimplementation needs a guarantee that a shared import gives for free.
The guarantee is golden vectors. A generator drives the real TypeScript handlers across 29 request scenarios — every status code, the version gate, both auth failures, shop normalisation, the idempotent upsert, malformed bodies — and records exactly what they answered: status, content type, body bytes, parsed JSON, and the resulting state of the credential replica. Plus seven fingerprint vectors chosen to break a careless implementation: the empty string, non-ASCII (the hash is over UTF-8 bytes, not code points), significant whitespace, an embedded NUL, a secret longer than one SHA-256 block.
The PHP test suite replays all of them through the real Laravel HTTP stack. Change either side and the other turns red. It is the only honest way to run the same contract in two languages.
If your app doesn't look like this
The adapter assumes you use kyon147/laravel-shopify (or osiset 17.x). If your Laravel app hand-rolls Shopify OAuth on Guzzle, or wraps the library in your own abstraction, the adapter may not fit — but the underlying contract still does, and it is small. The framework-agnostic version is the fleet contract for any stack: two HTTP routes and a per-shop credential lookup, roughly 80 lines in any language.