deploteka ← All guides

By DeploTeka · Last updated August 12, 2026

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-shopWhere the package reads itCovered by the hook?
OAuth begin/callback and all Admin API callsServices/ApiHelper::make()api_key, api_secret, api_versionyes
Webhook HMAC verificationHttp/Middleware/AuthWebhookapi_secret, keyed on the X-Shopify-Shop-Domain headeryes
Session-token (JWT) verificationObjects/Values/SessionToken::verifySignature() and verifyValidity()yes
The App Bridge apiKey in your Blade viewyour own viewone 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:

  1. OAuth runs against the requesting store's client_id/secret.
  2. 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.
  3. App Bridge (embedded apps only) initialises with the requesting store's client_id.
  4. 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.json first: ``json { "repositories": [ { "type": "vcs", "url": "https://github.com/fixelpixel/laravel-fleet" } ] } ` then composer 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:

ColumnNotes
shopunique; the store's *.myshopify.com domain, lowercased
client_idthat store's dedicated app
secrettext, stored with Laravel's encrypted cast
app_urlthat 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 tableusers by default, or whatever shopify-app.table_names.shops says. kyon147 ships no shops table; its migration adds columns to yours.
  • name holds the shop domain. password holds 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 NullableShopDomain value object — from AuthWebhook and SessionToken;
  • a plain string — from ApiHelper::make(), which calls ->toNative() first;
  • your Eloquent shop model — the ShopModel trait 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.

Frequently asked questions

Which versions of kyon147/laravel-shopify does the adapter support?

Every version published on Packagist: v17.3.3 (the oldest tag available) through v27.1.0, the current release. The adapter binds to config_api_callback, and the branch that invokes it inside Util::getShopifyConfig() is byte-identical in every one of those tags — only line numbers move. Laravel 10, 11, 12 and 13 are supported, PHP 8.1 and up. The end-to-end verification runs on PHP 8.3, Laravel 13.25 and kyon147 v27.1.0.

We are still on the dead osiset/laravel-shopify fork. Do we have to migrate first?

No. config_api_callback was added in osiset v17.1.1, and kyon147 inherited it unchanged when it forked, so the mechanism works identically on osiset 17.x. The PHP namespace is Osiset\ShopifyApp in both packages, which is also why moving to kyon147 later is a composer.json one-liner rather than a refactor. Only osiset 16 and below predate the hook; those apps use the universal contract guide instead. Migrating is worth doing on its own merits — kyon147 is maintained and installed roughly seventeen times more often — but it is never a prerequisite for being captured.

Does this work for a non-embedded or webhook-only Laravel app?

Yes, and it is less work. Non-embedded apps skip the App Bridge step entirely. Webhook HMAC verification is already shop-aware in the package — AuthWebhook reads the secret through Util::getShopifyConfig(‘api_secret’, $shop) keyed on the X-Shopify-Shop-Domain header — so a webhook-only service needs nothing beyond installing the package and setting the token.

What happens to stores that are already installed on our existing app?

Nothing changes for them. Your current SHOPIFY_API_KEY and SHOPIFY_API_SECRET stay in config/shopify-app.php and remain the fallback for every shop the adapter cannot resolve to a dedicated app. Existing installs keep authenticating against the app they installed, and stores move to their own dedicated app as DeploTeka provisions them. There is no cutover moment and no downtime window.

Do we have to invalidate a cache when a secret is rotated?

No, and this is one place the Laravel adapter is simpler than the Node ones. PHP is shared-nothing per request, so the next request after a rotation already reads the new value out of the database. The Express adapter needs an explicit onRegistered hook to drop a cached instance; here the same guarantee is structural. The only exception is a long-running worker such as Octane, where you can call forget() on the store.

We already use config_api_callback for our own logic. Does this overwrite it?

Never. The service provider checks whether a callable is already configured and leaves it alone if so. Resolve Deploteka\LaravelFleet\ConfigApiCallback from the container and delegate to it from your own closure for everything you do not override. One warning if you write your own instead: upstream routes every config key beginning with ‘api’ through the callback, not just api_key and api_secret, so a callback that returns null for the rest silently blanks api_version and api_scopes and the app fails in ways that look like Shopify errors.

How do we know the integration worked before pointing DeploTeka at it?

Two curls and one log line. Register a fake shop and confirm 200 with the bearer and 401 without it; read GET /api/fleet/installed and confirm it returns your clientId, a 16-character secretFingerprint and installed:false. Then hit your OAuth entry point with ?shop= for two different registered shops and check that the redirects carry two different client_id values. That last check is the whole feature.