Skip to content

OAuth And Catalog Sync

This doc covers the runtime flow that starts with an admin connecting a Shopify shop and ends with locally searchable shopify_products and shopify_product_variants rows.

flowchart TD
A["ShopifyConnectionPage::connect()"] --> B["ValidateShopifyShopDomainAction"]
B --> C["ShopifySiteContext::selectedSiteId()"]
C --> D["Route: capell-shopify-commerce.oauth.install"]
D --> E["ShopifyInstallController"]
E --> F["CreateShopifyOAuthStateAction"]
F --> G["BuildShopifyAuthorizeUrlAction"]
G --> H["Shopify admin/oauth/authorize"]
H --> I["Route: capell-shopify-commerce.oauth.callback"]
I --> J["ShopifyCallbackQueryData::from(request)"]
J --> K["ValidateShopifyHmacAction"]
K --> L["ValidateShopifyOAuthStateAction"]
L --> M["ExchangeShopifyAuthorizationCodeAction"]
M --> N["ConnectShopifyStoreAction"]
N --> O["SyncShopifyProductsAction::dispatch()"]

The install and callback routes both use web and auth middleware and both call ShopifyConnectionPage::canAccess(). A user without manage_shopify_commerce should never reach the Shopify OAuth redirect or callback handler.

DataOwnerNotes
Shopify app credentialsHost env/configSHOPIFY_APP_CLIENT_ID, SHOPIFY_APP_CLIENT_SECRET, capell-shopify-commerce.client_id, capell-shopify-commerce.client_secret.
OAuth nonceshopify_oauth_statesCreated by CreateShopifyOAuthStateAction, expires from capell-shopify-commerce.state_ttl_seconds.
Access tokenshopify_connections.access_tokenStored by ConnectShopifyStoreAction; do not expose outside trusted admin/server code.
Site scopeshopify_connections.site_idSelected through ShopifySiteContext; nullable only where the code explicitly allows a global connection.
Scopesshopify_connections.scopesDefaults come from ShopifyCommerceSettings::default_scopes or capell-shopify-commerce.default_scopes.
sequenceDiagram
participant Command as Sync command or admin action
participant Sync as SyncShopifyProductsAction
participant Start as StartShopifyProductBulkSyncAction
participant Shopify as Shopify GraphQL Admin API
participant Poll as PollShopifyProductBulkSyncAction
participant Import as ImportShopifyProductBulkSyncAction
participant DB as Local catalog tables
Command->>Sync: connection id or model
Sync->>Start: start bulkOperationRunQuery
Start->>Shopify: mutation ShopifyProductBulkSync
Shopify-->>Start: bulk operation id
Start->>DB: sync_status=running
Poll->>Shopify: currentBulkOperation
Shopify-->>Poll: COMPLETED with URL
Poll->>DB: sync_status=completed, bulk_operation_url=url
Import->>Shopify: HTTP GET JSONL URL
Import->>DB: upsert products and variants, prune stale rows
Import->>DB: sync_status=idle, last_synced_at=now

SyncShopifyProductsAction and the bulk sync sub-actions use the lock key capell-shopify-commerce.sync.{connectionId}. The queued Action middleware uses WithoutOverlapping with a six-hour expiry; the start, poll, and import Actions also use cache locks around their critical sections.

SearchShopifyProductsAction searches the local cache first. If there are no local matches and the term is not empty, it performs a live Shopify GraphQL product search, persists any returned products, invalidates the connection search version, and searches locally again.

Search cache keys use:

capell-shopify-commerce.search.{connectionId}.{version}.{sha1LowercaseTerm}.{limit}

The TTL comes from shopify_commerce.search_cache_ttl_minutes, with a fallback of five minutes.

BoundaryFailure behavior
Invalid shop domainInstall route aborts with 422; admin page adds a field error.
Missing app credentialsInstall route redirects back to filament.admin.pages.shopify-commerce with a configuration error.
Bad callback HMACCallback logs Shopify OAuth failed and redirects back with an OAuth error.
Expired or wrong-user stateCallback logs Shopify OAuth failed and shows the invalid-state message.
GraphQL request failureExecuteShopifyAdminGraphqlAction throws ShopifyGraphqlException.
Bulk start failureConnection sync_status becomes failed; GraphQL user errors also mark connection status as error.
Bulk poll failure statusFAILED or CANCELED marks sync_status as lowercase status and connection status as error.
Import failureConnection sync_status becomes failed, connection status becomes error, and last_sync_error stores the exception message.
Revoked connectionSync/import returns without reactivating the connection.
SymptomLikely causeCheckFix
Shopify rejects the OAuth redirectWrong Shopify app client id or callback URLCompare SHOPIFY_APP_CLIENT_ID and app callback URL with route('capell-shopify-commerce.oauth.callback')Update the Shopify app settings and clear host config cache.
OAuth callback always says it failedSecret mismatch causing bad HMACLaravel log line Shopify OAuth failed with invalid_hmac; config key capell-shopify-commerce.client_secretSet SHOPIFY_APP_CLIENT_SECRET to the current Shopify app secret.
OAuth state is invalidNonce expired or callback handled by another signed-in userInspect shopify_oauth_states.nonce, user_id, shop_domain, and expires_atRestart OAuth from the same browser session; increase state_ttl_seconds only if admins consistently exceed ten minutes.
Sync stays queuedQueue worker is not processing dispatched ActionsCheck shopify_connections.sync_status, last_sync_queued_at, queue worker logs, and failed jobsStart the host queue worker or run the sync command for the connection while debugging.
Sync stays runningBulk operation was started but not polled/importedCheck bulk_operation_id, bulk_operation_url, and Shopify current bulk operation statusRun or schedule the poll/import workflow that calls PollShopifyProductBulkSyncAction and ImportShopifyProductBulkSyncAction.
Search returns old productsImport did not complete or search cache still has the old versionCheck shopify_products.synced_at, shopify_connections.last_synced_at, and cache prefix capell-shopify-commerce.search.Complete import and run InvalidateShopifyProductSearchCacheAction::run($connection).
Site-limited admin sees no connectionConnection is scoped to a site the actor cannot accessCheck shopify_connections.site_id and the actor’s assigned site idsReconnect under the intended site or update the actor’s site assignment.
GraphQL calls pause brieflyShopify throttle metadata reports insufficient pointsInspect response extensions.cost.throttleStatus in a fake or captured responseThis is expected; ExecuteShopifyAdminGraphqlAction sleeps up to five seconds to pace requests.
ChangeTest recipe
OAuth install URL or shop validationAdd or update ShopifyInstallControllerTest; assert route status, stored OAuth state, and redirect query.
Callback validationAdd or update ShopifyCallbackControllerTest; fake token exchange HTTP, assert bad HMAC/state failures, and assert valid callback creates a connection.
HMAC logicExtend ValidateShopifyHmacActionTest with sorted query values and mutation cases.
Token exchangeExtend ExchangeShopifyAuthorizationCodeActionTest; fake Shopify token HTTP and assert ShopifyTokenExchangeResponseData.
Bulk operation mutationExtend SyncShopifyProductsActionTest; fake GraphQL responses and assert connection status fields.
Import mappingExtend SyncShopifyProductsActionTest; fake JSONL download and assert product, variant, stale-prune, and money precision behavior.
Search cacheExtend SearchShopifyProductsActionTest; seed multiple connections and assert cache keys stay connection-scoped.
Public-output safetyAdd a frontend or render test in the consuming package; assert tokens, OAuth state, internal routes, raw_snapshot, and admin URLs are absent from public HTML.

Run the full Shopify Commerce package tests from the repository root:

Terminal window
vendor/bin/pest packages/shopify-commerce/tests --configuration=phpunit.xml
Next docUse it for
OverviewPackage boundary, provider boot, runtime surfaces, and extension guidance.
Package READMEInstall commands and the concise maintenance checklist.
Search drivers and loggingAdjacent patterns for search-related driver behavior and logs.