diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5af24b1..86c180b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ on: workflow_dispatch: push: branches: - - '6.x' + - '7.x' pull_request: permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index cd89b49..b54c655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,68 @@ # Release Notes for Shopify -## Unreleased +## 7.0.1 - 2026-03-20 - Fixed a PHP error that could occur when retrieving products. ([#202](https://github.com/craftcms/shopify/issues/202)) +## 7.0.0 - 2026-03-19 + +> [!IMPORTANT] +> Shopify for Craft 7.x uses a new app-based authorization system. +> Follow the [upgrade instructions](https://github.com/craftcms/shopify/blob/7.x/README.md#upgrading) to get new credentials. + +- Shopify for Craft now requires version `2026-01` of Shopify’s GraphQL Admin API. +- Shopify for Craft now requires Shopify PHP SDK 6.0 or later. +- Added support for setting the base webhook and auth URL using the `SHOPIFY_PUBLIC_DEV_URL` environment variable. ([#185](https://github.com/craftcms/shopify/issues/185)) +- Product conditions can now have a “Template Suffix” rule. +- Added the “Shopify Sync” permission. +- Added the `templateSuffix` product query param. +- Added `craft\shopify\collections\VariantCollection`. +- Added `craft\shopify\console\controllers\ApiController`. +- Added `craft\shopify\controllers\AuthController`. +- Added `craft\shopify\db\Table::ACCESS_TOKENS`. +- Added `craft\shopify\elements\conditions\products\TemplateSuffixConditionRule`. +- Added `craft\shopify\elements\db\ProductQuery::$templateSuffix`. +- Added `craft\shopify\elements\db\ProductQuery::templateSuffix()`. +- Added `craft\shopify\events\DefineGqlFieldsEvent`. +- Added `craft\shopify\events\DefineGqlQueryArgumentsEvent`. +- Added `craft\shopify\fieldlayoutelements\MediaField`. +- Added `craft\shopify\fieldlayoutelements\MetafieldsField`. +- Added `craft\shopify\fieldlayoutelements\OptionsField`. +- Added `craft\shopify\fieldlayoutelements\VariantsField`. +- Added `craft\shopify\models\Settings::getAuthUrl()`. +- Added `craft\shopify\models\Settings::getClientId()`. +- Added `craft\shopify\models\Settings::getClientSecret()`. +- Added `craft\shopify\models\Settings::setClientId()`. +- Added `craft\shopify\models\Settings::setClientSecret()`. +- Added `craft\shopify\models\Variant`. +- Added `craft\shopify\records\AccessToken`. +- Added `craft\shopify\services\Api::API_ACCESS_TOKEN_ENV_VAR`. +- Added `craft\shopify\services\Api::EVENT_DEFINE_GQL_QUERY_ARGUMENTS`. +- Added `craft\shopify\services\Api::EVENT_DEFINE_PRODUCT_GQL_FIELDS`. +- Added `craft\shopify\services\Api::getAccessToken()`. +- Added `craft\shopify\services\Api::initializeContext()`. +- `craft\shopify\elements\Product::getCheapeastVariant()` now returns a `craft\shopify\models\Variant` object. +- `craft\shopify\elements\Product::getDefaultVariant()` now returns a `craft\shopify\models\Variant` object. +- `craft\shopify\elements\Product::getVariants()` now returns a collection. +- Deprecated the `--throttle` option for `shopify/sync` commands. +- Deprecated `craft\shopify\models\Settings::getApiKey()`. `getClientId()` should be used instead. +- Deprecated `craft\shopify\models\Settings::getApiSecretKey()`. `getClientSecret()` should be used instead. +- Deprecated `craft\shopify\models\Settings::setApiKey()`. `setClientId()` should be used instead. +- Deprecated `craft\shopify\models\Settings::setApiSecretKey()`. `setClientSecret()` should be used instead. +- Removed the `publishedOnCurrentPublication` product query param. +- Removed `craft\shopify\controllers\ProductsController::actionRenderCardHtml()`. +- Removed `craft\shopify\elements\Product::$publishedOnCurrentPublication`. +- Removed `craft\shopify\elements\Product::getBodyHtml()`. +- Removed `craft\shopify\elements\Product::setBodyHtml()`. +- Removed `craft\shopify\elements\db\ProductQuery::$publishedOnCurrentPublication`. +- Removed `craft\shopify\elements\db\ProductQuery::publishedOnCurrentPublication()`. +- Removed `craft\shopify\handlers\Product`. +- Removed `craft\shopify\helpers\Metafields`. +- Removed `craft\shopify\models\Settings::$syncProductMetafields`. +- Removed `craft\shopify\models\Settings::$syncVariantMetafields`. +- Removed `craft\shopify\services\Products::syncAllProducts()`. +- Fixed a bug where product slugs weren’t syncing correctly. + ## 6.1.3 - 2026-01-19 - Fixed a PHP error that could occur when contextual pricing countries aren’t set. ([#191](https://github.com/craftcms/shopify/issues/191)) @@ -192,7 +251,7 @@ - Shopify now requires Craft CMS 5.0.0-beta.10 or later. -## 4.1.2 - 2024-04-15 +## 4.1.2 - 2024-04-15 - Fixed a PHP error that could occur when syncing products with emojis. ([#107](https://github.com/craftcms/shopify/issues/107)) - Fixed a PHP error that could occur when syncing products. ([#105](https://github.com/craftcms/shopify/issues/105)) diff --git a/README.md b/README.md index 7d70b00..93e52b3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com) products into [Craft CMS](https://craftcms.com/). > [!IMPORTANT] -> Version 6.x of the Shopify plugin uses the new [GraphQL Admin API](https://shopify.dev/docs/api/admin-graphql) to [set up webhooks](#set-up-webhooks) and [synchronize](#synchronization) data. Review the [Upgrading](#upgrading) section for more info about the impacts of this change. +> Version 7.x of Shopify for Craft uses a new app-based authorization system. +> You must follow the [upgrade instructions](#upgrading) to get new credentials. ## Topics @@ -39,127 +40,205 @@ To install the plugin, visit the [Plugin Store](https://plugins.craftcms.com/sho php craft plugin/install shopify ``` -### Create a Shopify App +## Connect to Shopify -The plugin works with Shopify’s [Custom Apps](https://help.shopify.com/en/manual/apps/custom-apps) system. +The plugin works with Shopify’s [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard) app system, and is split into two primary parts: [creating an app](#create-an-app) and [performing authorization](#install-in-a-store). -> [!NOTE] -> If you are not the owner of the Shopify store, have the owner add you as a collaborator or staff member with the [_Develop Apps_ permission](https://help.shopify.com/en/manual/apps/custom-apps#api-scope-permissions-for-custom-apps). - -Follow [Shopify’s directions](https://help.shopify.com/en/manual/apps/custom-apps) for creating a private app (through the _Get the API credentials for a custom app_ section), and take these actions when prompted: +To install an app into a store, one of these statements must describe your account’s relationship with it: +- You are the owner of the store; +- You have been added as a collaborator on the store, with the [App developer role](https://shopify.dev/docs/apps/build/dev-dashboard/user-permissions) (see screenshot, below); +- You are working with a [dev store](https://shopify.dev/docs/apps/build/dev-dashboard/development-stores) or [client transfer store](https://help.shopify.com/en/partners/manage-clients-stores/client-transfer-stores/create-client-transfer-stores) belonging to your Partner organization; -1. **App Name**: Choose something that identifies the integration, like “Craft CMS.” -2. **Admin API access scopes**: The following scopes are required for the plugin to function correctly: +![Adding a collaborator via the Shopify admin](docs/shopify-add-collaborator.png) - - `read_products` - - `read_product_listings` - - `read_inventory` - - Additionally (at the bottom of this screen), the **Webhook subscriptions** → **Event version** should be `2025-07`. +> [!CAUTION] +> The new OAuth-based API connection requires that apps are created from an “organization” that has access to the [Partner Dashboard](https://www.shopify.com/partners). +> Standalone stores (like the one created when you sign up for a Shopify account) belong to their own organization. +> +> - If you are working with a store or account that has never accessed a Partner Dashboard, **you must create a Partner profile before proceeding**. +> - When working from an account that has access to multiple organizations, **it is generally safest to access the new Dev Dashboard _via_ the Partner Dashboard you want the app associated with.** + +### Create an App + +1. Navigate to your **Dev Dashboard**: + - From a store, open the account context menu (upper-right corner) and select **Dev Dashboard**; + - From the Partner Dashboard, open the account context menu (upper-right corner) and select **Dev Dashboard**; +1. In the Dev Dashboard, press **Create app**. +1. In the first screen, pick an **App name** that identifies the integration, like _Craft CMS_. +1. Press **Create**, then fill out the following fields to create your first “version”: + - **App URL**: Retrieve the **Shopify App Auth URL** value from the plugin’s setting screen in the Craft control panel. (This will always be your project’s URL, followed by the [cpTrigger](https://craftcms.com/docs/5.x/reference/config/general.html#cptrigger), then the action `shopify/auth`: `https://my-project.com/admin/shopify/auth`.) + - **Embed app in Shopify admin**: Make sure this is _unchecked_, as the plugin does not support embedded apps. + - **Webhooks API Version**: Choose `2026-01`, and add the same string to your project’s `.env` file: + ```bash + SHOPIFY_WEBHOOK_VERSION="2026-01" + ``` + - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly: + - `read_inventory` + - `read_product_listings` + - `read_products` + - Shopify requires these to be in a comma-separated list: + ``` + read_inventory,read_product_listings,read_products + ``` + - Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation. +1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number. +1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file: + ```bash + SHOPIFY_CLIENT_ID="..." # Client ID + SHOPIFY_CLIENT_SECRET="..." # Secret + ``` + +Next, you’ll configure the app’s _distribution_ scheme. + +1. From the new app’s **Home** screen in the Dev Dashboard, follow the **Select distribution method** link, within the **Distribution** widget. +1. The Partner Dashboard will open, with your app selected. Choose **Custom distribution**, press **Select**, then confirm in the dialog box. +1. Locate your store’s _hostname_ (see screenshot, below), and paste it into the **Store domain** field, then press **Generate link**. + - _Once you choose a hostname, the app is permanently locked to that store. If you do not provide the correct hostname at this stage, you’ll need to delete the app and start over._ + - If you want to use the same connection across multiple related stores, check **Allow multi-store install for one Plus organization**. + - Take this opportunity to add the hostname to your `.env` file: + ```bash + SHOPIFY_HOSTNAME="my-store-name.myshopify.com" + ``` +1. Return to the **Distribution** screen and press **Copy link**. + +![Identifying your store’s hostname, used when creating a distribution](docs/shopify-hostname.png) + +You should now have a total of _four_ `SHOPIFY_*` variables in your `.env` file: + +```bash +# 1. Webhook API Version +# This is tied to your app’s release, and should not change (except potentially during a future plugin upgrade). +SHOPIFY_WEBHOOK_VERSION="2026-01" + +# 2. Client ID +# This can be found in your Shopify app’s Settings screen. +SHOPIFY_CLIENT_ID="..." + +# 3. Secret +# This can be found in your Shopify app’s Settings screen. +SHOPIFY_CLIENT_SECRET="..." + +# 4. Hostname +# Found in your store’s settings screen. Include only the domain (no leading `https://`) +SHOPIFY_HOSTNAME="my-store-name.myshopify.com" +``` -3. **Storefront API access scopes**: The following scopes are required for the plugin to function correctly: +In the Craft control panel, navigate to **Shopify** → **Settings** to configure the plugin: - - `unauthenticated_read_product_listings` +- **API Version**: `$SHOPIFY_WEBHOOK_VERSION` +- **Client ID**: `$SHOPIFY_CLIENT_ID` +- **Client Secret Key**: `$SHOPIFY_CLIENT_SECRET` +- **Host Name**: `$SHOPIFY_HOSTNAME` -4. **Admin API access token**: Reveal and copy this value into your `.env` file, as `SHOPIFY_ADMIN_ACCESS_TOKEN`. -5. **API key and secret key**: Reveal and/or copy the **API key** and **API secret key** into your `.env` under `SHOPIFY_API_KEY` and `SHOPIFY_API_SECRET_KEY`, respectively. +Use these literal strings in the corresponding fields. +As you type the `$`-prefixed value into an input, Craft will [suggest](https://craftcms.com/docs/5.x/system/project-config.html#secrets-and-the-environment) matching variables. -#### Store Hostname +Press **Save** to commit the settings to [project config](https://craftcms.com/docs/5.x/system/project-config.html). -The last piece of info you’ll need on hand is your store’s hostname. This is usually what appears in the browser when using the Shopify admin—it’s also shown if you navigate to the `Settings -> Domains` screen of your store: +> [!TIP] +> You may see a warning below the read-only **Shopify App Auth URL** field. +> This is expected, until you’ve completed the OAuth flow! -Screenshot of the settings screen in the Shopify admin, with an arrow pointing to the store’s default hostname in the sidebar. +### Install in a Store -Save this value (_without_ the leading `http://` or `https://`) in your `.env` as `SHOPIFY_HOSTNAME`. +In this step, we’ll perform the [authorization code grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant) or _OAuth_ flow, during which Craft and Shopify negotiate a long-lived access token. -> [!NOTE] -> The hostname required is the one ending with `myshopify.com` or `myshopify.io`. +> [!TIP] +> Whoever installs the app must be able to access to the store _and_ the Craft project from the same browser. +> Shopify does _not_ need to directly contact the Craft, so you may do this from your local development machine! + +1. Visit the installation URL you copied from the **Distribution** screen in the Partner Dashboard. You must be logged in to a Shopify account with access to the target store (but it does not need to be the same account that created the app). +1. Select the store in Shopify’s context picker. +1. On the **Install app** screen within the store’s admin, review the permissions and press **Install**. + > [!WARNING] + > If you do not see a blue banner confirming **This app is exclusive to your store**, _do not proceed_! + > A banner saying **This app can’t be installed on this store** (or landing on a generic Shopify error page) usually means that the hostname is not valid for the distribution. +1. You will be redirected to the Craft control panel “auth” URL you used when creating the Shopify app. (If you were not already logged in, Craft will ask for your username and password; your user must have the **Access Shopify** permission or be an administrator to complete the authorization flow.) +1. Press **Authorize** in the dialog. +1. Craft and Shopify will perform the OAuth handshake, and you should land on a confirmation screen in the Craft control panel saying **Your Shopify app has been successfully authorized**. + +🎊 Congratulations! Your Craft project can now communicate with the Shopify API. +Let’s take it for a spin by importing your store’s products. -At this point, you should have the following Shopify-specific values: +### Set up Webhooks -```env -# ... +A new **Webhooks** tab will appear in the **Shopify** section of the control panel once you’ve completed the authorization flow. -SHOPIFY_ADMIN_ACCESS_TOKEN="..." -SHOPIFY_API_VERSION="2025-07" -SHOPIFY_API_KEY="..." -SHOPIFY_API_SECRET_KEY="..." -SHOPIFY_HOSTNAME="my-storefront.myshopify.com" -``` +Click **Create webhooks** on the Webhooks screen to add the required webhooks to Shopify. +The plugin will use your newly-issued access token to perform this operation, so this also serves as an initial communication test. -### Connect Plugin +> [!WARNING] +> You must add webhooks for every environment you deploy the plugin to; webhooks are tied to the specific, registered URL. +> Be aware that Shopify will continue to attempt delivery to your development environment’s subscriptions, which may impact the statistics you see in the Dev Dashboard. +> See [Cleanup](#cleanup) below for help culling unused webhook subscriptions. -Now that you have credentials for your custom app, it’s time to add them to Craft. +#### Testing Webhooks -1. Visit the **Shopify** → **Settings** screen in your project’s control panel. -2. Assign the four environment variables to the corresponding settings, using the special [config syntax](https://craftcms.com/docs/5.x/configure.html#control-panel-settings): - - **API Version**: `$SHOPIFY_API_VERSION` - - **API Key**: `$SHOPIFY_API_KEY` - - **API Secret Key**: `$SHOPIFY_API_SECRET_KEY` - - **Access Token**: `SHOPIFY_ADMIN_ACCESS_TOKEN` - - **Host Name**: `$SHOPIFY_HOSTNAME` -3. Click **Save**. +Development environments are not typically exposed to the public internet, which means Shopify won’t be able to deliver webhooks. +To test synchronization in development, we recommend using [ngrok](https://ngrok.com/) to create a tunnel to your local environment. +DDEV makes this simple, with [the `ddev share` command](https://ddev.readthedocs.io/en/latest/users/topics/sharing/). -> [!NOTE] -> These settings are stored in [Project Config](https://craftcms.com/docs/5.x/system/project-config.html), and will be automatically applied in other environments. [Webhooks](#set-up-webhooks) will still need to be configured for each environment! +> [!TIP] +> Use the `SHOPIFY_PUBLIC_DEV_URL` environment variable to override your project’s base URL when creating webhooks; this allows you to continue using your regular DDEV site URL for control panel and front-end access, rather than overriding the entire project or site’s base URL. +> +> This setting may not work if you have set a custom `cpBaseUrl`! -### Set up Webhooks +#### Cleanup -Once your credentials have been added to Craft, a new **Webhooks** tab will appear in the **Shopify** section of the control panel. +Each time you open an `ngrok` tunnel, you get a new public URL, and Shopify will be unable to deliver webhooks. +This means that you may accumulate broken subscriptions over the course of development. +In the control panel, we only display the webhooks relevant to the _current_ environment—or, more accurately, those with a `uri` matching the resolved webhook URL (which can be influenced by the `SHOPIFY_PUBLIC_DEV_URL` variable). -Click **Create** on the Webhooks screen to add the required webhooks to Shopify. The plugin will use the credentials you just configured to perform this operation—so this also serves as an initial communication test. +You can delete individual webhooks from the control panel, or by using the [CLI GraphQL playground](#graphql-playground)… -> [!WARNING] -> You will need to add webhooks for each environment you deploy the plugin to, because each webhook is tied to a specific URL. +```bash +php craft shopify/api/query 'mutation deleteWebhook { + webhookSubscriptionDelete(id: "gid://shopify/WebhookSubscription/123456789") { + userErrors { + field + message + } + deletedWebhookSubscriptionId + } +}' +``` -> [!NOTE] -> To test synchronization in development, we recommend using [ngrok](https://ngrok.com/) to create a tunnel to your local environment. DDEV makes this simple, with [the `ddev share` command](https://ddev.readthedocs.io/en/latest/users/topics/sharing/). Keep in mind that your primary site’s base URL is used when registering webhooks, so you may need to update it to match the ngrok tunnel, then recreate your webhooks. +…substituting a known subscription GID. +Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://shopify.dev/docs/api/admin-graphql/2026-01/queries/webhookSubscriptions) query. ## Upgrading -To guarantee that the plugin can access all the Shopify resources it needs, review **Admin API access scopes** and **Storefront API access scopes** in the [requirements](#create-a-shopify-app) section _before_ performing an upgrade. +This release (7.x) is primarily concerned with Shopify API compatability, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify). -_After_ upgrading, check that the required webhooks are in place by visiting **Shopify** → **Webhooks** in the Craft control panel. The plugin will retrieve all the webhooks for your storefront, and display a **Create** button if any are missing for the current environment. +Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable. -> [!NOTE] -> You must create webhooks for each environment. Repeat this process in your live environment, after deploying. +> [!TIP] +> We strongly recommend reviewing this same section on the [6.x](https://github.com/craftcms/shopify/blob/6.x/README.md#upgrading) branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. -The remainder of this section applies specifically to the 5.x → 6.x upgrade. Review the [changelog](CHANGELOG.md) for a complete list of added, removed, and deprecated APIs. +After the upgrade, you **must** [delete and re-create](#set-up-webhooks) webhooks for each environment. Webhooks are registered and delivered with a specific version, and a mismatch will result in errors. -### Deprecated Settings +Your “legacy custom app” can be left as-is or deleted, once all your environments have been migrated to the Dev Dashboard connection. While this plugin has no need for those credentials, confirm with the store owner that no other external services depend on them! -The `syncProductMetafields` and `syncVariantMetafields` are no longer used, and should be removed from your [configuration file](#settings). Meta fields are now automatically loaded alongside product and variant data. +### Credentials -### Property Names +At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new [Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard). -Accessors on our [product element](#native-attributes) remain stable, but with the shift to the GraphQL Admin API, many _canonical_ property names on products and variants have changed. If you directly output properties of _variants_ in your templates, they are apt to need updates. The [`ProductVariant` model documentation](https://shopify.dev/docs/api/admin-rest/2025-01/resources/product-variant) shows how to translate old property names (teal) to the new GraphQL schema (magenta). +You should be able to [create a new app](#create-an-app), and [install it](#install-in-a-store) using the new OAuth mechanism, without disruption to product synchronization. -### Contextual Pricing +### Publishing and Status -Shopify’s “presentment prices” are now referred to as “contextual pricing.” Variant arrays still have the default `price` and `compareAtPrice` fields (previously `price` and `compare_at_price`, respectively), but to fetch context-dependent prices, you must provide a list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/latest/enums/CountryCode) via the **Contextual Pricing Countries** setting. _Product data must be [sychronized](#synchronization) after changing this setting._ +Shopify has eliminated [sales channels for custom apps](https://shopify.dev/docs/apps/build/sales-channels/start-building), and therefore the [`publishedOnCurrentPublication` field](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product#field-Product.fields.publishedOnCurrentChannel) is no longer available in Product queries. -Contextual prices are stored among other variant properties, with keys corresponding to each country code. For example: `US` pricing would be available as `usContextualPricing`; `DE` pricing would be available as `deContextualPricing`. Each contextual price has this structure: +This means that there is no official way to “publish” products to the Craft integration, but we cover some alternatives in the [sales channel emulation](#emulate-sales-channels) section. -```php -[ - 'price' => [ - 'amount' => '50.0', - 'currencyCode' => 'USD', - ], - 'compareAtPrice' => null, -] -``` +### Product Field Layouts -You can display these prices using Craft’s [built-in currency formatter](https://craftcms.com/docs/5.x/reference/twig/filters.html#currency): +The product element editor has received a major overhaul. You can now choose exactly where Shopify data is placed, within the [field layout](#custom-fields). -```twig -{% set usPrice = variant.usContextualPricing.price %} -{{ usPrice.amount|currency(usPrice.currency) }} -``` - -### Resource IDs +### Front-End SDKs -The GraphQL API no longer uses numeric IDs to look up objects; instead, it expects a [new `gid://`-prefixed value](https://shopify.dev/docs/api/admin-graphql/latest/scalars/ID). [Product elements](#product-element) expose this as `shopifyId` (so as to avoid conflicts with the internal, Craft-specific _element_ `id` property), but it appears at the top level of other resources, like [options](#using-options), [variants](#variants-and-pricing), and media. +Shopify has retired many of its pre-built client-side frameworks, in favor of directly communicating with the generic [Storefront GraphQL API](#storefront-api-client). +You will need to revise how you query and mutate data, if your front-end currently depends on the JS Buy SDK or Buy Button JS. ## Product Element @@ -167,7 +246,7 @@ Products from your Shopify store are represented in Craft as product [elements]( ### Synchronization -Once the plugin has been configured, you can perform an initial synchronization of all products via the control panel (via **Utilities** → **Shopify Sync**) or the command line: +Once connected to Shopify, you can perform an initial synchronization of all products, from the control panel (via **Utilities** → **Shopify Sync**) or the command line: ```sh php craft shopify/sync/products @@ -177,33 +256,43 @@ This adds a [bulk operation](https://shopify.dev/docs/api/usage/bulk-operations/ Going forward, your products are automatically kept in sync via [webhooks](#set-up-webhooks). You can view a history of synchronization operations by visiting the **Shopify Sync** utility. +> [!WARNING] +> We do our best to capture native Shopify resources that are attached to a product (like variants, media, and options), but cannot dynamically discover relationships with other content via `Metafield`s, or data from third-party apps. +> Additional fields can be captured by listening [events](#events) in a custom module. + ### Native Attributes -In addition to the standard element attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2025-07/objects/Product): - -| Attribute | Description | Type | -|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ---------- | -| `shopifyId` | The unique product [identifier](https://shopify.dev/docs/api/admin-graphql/latest/scalars/ID) in your Shopify store. | `String` | -| `shopifyStatus` | The status of the product in your Shopify store. Values can be `active`, `draft`, or `archived`. | `String` | -| `handle` | The product’s “URL handle” in Shopify, equivalent to a “slug” in Craft. For existing products, this is visible under the **Search engine listing** section of the edit screen. | `String` | -| `productType` | The product type of the product in your Shopify store. | `String` | -| `descriptionHtml` | Product description. Use the `\|raw` filter to output it in Twig—but only if the content is trusted. This was previously called `bodyHtml`. | `String` | -| `tags` | Tags associated with the product in Shopify. | `Array` | -| `templateSuffix` | [Liquid template suffix](https://shopify.dev/themes/architecture/templates#name-structure) used for the product page in Shopify. | `String` | -| `vendor` | Vendor of the product. | `String` | -| `metaFields` | [Metafields](https://shopify.dev/docs/api/admin-graphql/latest/objects/Metafield) associated with the product. | `Array` | -| `images` | Images attached to the product in Shopify. The complete [ProductImage resources](https://shopify.dev/docs/api/admin-graphql/latest/objects/MediaImage) are stored in Craft. | `Array` | -| `options` | [ProductOption](https://shopify.dev/docs/api/admin-graphql/latest/objects/ProductOption) objects, as configured in Shopify. Each option has a `name`, `position`, and an array of in-use `values`. | `Array` | -| `createdAt` | When the product was created in your Shopify store. (This will almost always be different from the element’s native `dateCreated` property.) | `DateTime` | -| `publishedAt` | When the product was published in your Shopify store. | `DateTime` | -| `updatedAt` | When the product was last updated in your Shopify store. (This will almost always be different from the element’s native `dateUpdated` property.) | `DateTime` | +In addition to the standard element attributes like `id`, `title`, and `status`, each Shopify product element contains direct accessors for these canonical Shopify [Product attributes](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product): + +| Attribute | Description | Type | +|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------| +| `shopifyId` | The integer product ID from Shopify. | `Integer` | +| `shopifyGid` | The [unique resource identifier](https://shopify.dev/docs/api/admin-graphql/2026-01/scalars/ID) (“GID”) from Shopify. This should always be the `shopifyId`, prepended with `gid://shopify/Product/`. | `String` | +| `shopifyStatus` | The status of the product in Shopify. Values can be `active`, `draft`, or `archived`. | `String` | +| `handle` | The product’s “URL handle” in Shopify, equivalent to a “slug” in Craft. For existing products, this is visible under the **Search engine listing** section of the edit screen. | `String` | +| `productType` | The product type of the product in your Shopify store. | `String` | +| `descriptionHtml` | Product description. Output with the `\|raw` Twig filter—but only if the content is trusted. This was previously called `bodyHtml`. | `String` | +| `tags` | Tags associated with the product in Shopify. | `Array` | +| `templateSuffix` | [Liquid template suffix](https://shopify.dev/themes/architecture/templates#name-structure) used for the product page in Shopify. | `String` | +| `vendor` | Vendor of the product. | `String` | +| `data` | The raw API response data from Shopify. (See below) | `Array` | +| `metaFields` | [Metafields](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Metafield) associated with the product. | `Array` | +| `images` | Images (or “Media”) attached to the product in Shopify. The complete [MediaImage](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/MediaImage) objects are stored in Craft. | `Array` | +| `options` | [ProductOption](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductOption) objects, as configured in Shopify. Each option has a `name`, `position`, and an array of in-use `values`. | `Array` | +| `defaultVariant` (and `cheapestVariant`) | The first known (or cheapest) variant belonging to the product. This is one of the few ancillary resources that we make available as a model (`craft\shopify\models\Variant`). | `Variant` | +| `createdAt` | When the product was created in your Shopify store. (This will almost always be different from the element’s native `dateCreated` property.) | `DateTime` | +| `publishedAt` | When the product was published in your Shopify store. | `DateTime` | +| `updatedAt` | When the product was last updated in your Shopify store. (This will almost always be different from the element’s native `dateUpdated` property.) | `DateTime` | All of these properties are available when working with a product element [in your templates](#templating). +Yii and Twig also allow you to access some values via magic getters—any [method](#methods) beginning with `get` (like `product.getDefaultVariant()`) can also be treated like a property (`product.defaultVariant`). -> [!IMPORTANT] -> See the Shopify documentation on the [product resource](https://shopify.dev/docs/api/admin-graphql/latest/objects/Product) for more information about what kinds of values to expect from these properties. +> [!IMPORTANT] +> See the Shopify documentation on the [product resource](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/Product) for more information about what kinds of values to expect from these properties. +> The nature of GraphQL (and API versioning) means that we may not be capturing 100% of the available data. +> To select additional fields, you can intercept the [event](#events) emitted just before a product GraphQL query is sent. -A complete copy of the Shopify API data used to populate a product element is available under its `data` property. Wherever possible, we have used Shopify’s native property names—but by virtue of fetching products via GraphQL, there may be differences between the structure of this object and the API documentation, especially as it relates to nested objects. Use the following [methods](#methods) to access related or nested data! +A complete copy of the requested Shopify API data used to populate a `Product` element is available under its `data` property. Wherever possible, we have used Shopify’s native property names—but by virtue of fetching products via GraphQL, there may be differences between the structure of this object and the API documentation, especially as it relates to nested objects. Use the following [methods](#methods) to access related or nested data! ### Methods @@ -211,7 +300,8 @@ The product element has a few methods you might find useful in your [templates]( #### `Product::getVariants()` -Returns an array of [variants](#variants-and-pricing) belonging to the product. Each variant is an associative array—_not_ an element—but you can use the same dot notation to access their properties: +Returns an array of [variants](#variants-and-pricing) belonging to the product. +Variants are _not_ elements (just regular models), but you can use the same dot notation to access their properties: ```twig {% set variants = product.getVariants() %} @@ -256,7 +346,7 @@ Shortcut for getting the lowest-priced [variant](#variants-and-pricing) belongin Starting at {{ cheapestVariant.price|currency }}! ``` -Note that this does not factor in [contextual pricing](#contextual-pricing). +Note that this does not factor in [contextual pricing](https://shopify.dev/docs/api/admin-graphql/latest/objects/Product#field-Product.fields.contextualPricing). #### `Product::getShopifyUrl()` @@ -283,9 +373,14 @@ For administrators, you can even link directly to the Shopify admin: ### Custom Fields -Products synchronized from Shopify have a dedicated field layout, which means they support Craft’s full array of [content tools](https://craftcms.com/docs/5.x/system/fields.html). +Products synchronized from Shopify have a dedicated field layout, which means they support Craft’s full array of [content tools](https://craftcms.com/docs/5.x/system/fields.html). In addition, you may place these read-only native fields anywhere in the layout to customize your authoring experience: + +- **Variants:** A static table with variants’ names, SKUs, and prices. +- **Options:** A list of defined options, their options, and whether any variants exist +- **Meta fields:** A static table displaying product meta fields as key-value pairs. +- **Media:** Displays a list of images attached to the product. -The product field layout can be edited by going to **Shopify** → **Settings** → **Products**, and scrolling down to **Field Layout**. +The product field layout can be edited by going to **Shopify** → **Settings** → **Products**. Fields are accessible from any product element, by their handle: @@ -306,7 +401,14 @@ Variants and other nested records do not support custom fields. ### Routing -You can give synchronized products their own on-site URLs. To set up the URI format (and the template that will be loaded when a product URL is requested), go to **Shopify** → **Settings** → **Products**. +You can give synchronized products their own on-site URLs. To set up the URI format (and the template that will be loaded when a product URL is requested), go to **Shopify** → **Settings** → **Products**. A URI format that emulates Shopify’s default would look something like this: + +``` +products/{handle} +``` + +Any [native attribute](#native-attributes), [custom field](#custom-fields) handle, or other base element property can be used in this template to construct a URL. +Product elements’ slugs are automatically synchronized with the `handle` set in Shopify, so `{slug}` (as you might use in an entry’s URI format) is equivalent to `{handle}`. If you would prefer your customers to view individual products on Shopify, clear out the **Product URI Format** field on the settings page, and use [`product.shopifyUrl`](#productgetshopifyurl) instead of `product.url` in your templates. @@ -314,13 +416,13 @@ If you would prefer your customers to view individual products on Shopify, clear A product’s `status` in Craft is a combination of its `shopifyStatus` attribute ('active', 'draft', or 'archived') and its enabled state. The former can only be changed from Shopify; the latter is set in the Craft control panel. -> **Note** +> [!NOTE] > Statuses in Craft are often a synthesis of multiple properties. For example, an entry with the _Pending_ status just means it is `enabled` _and_ has a `postDate` in the future. In most cases, you’ll only want to display “Live” products, or those which are _Active_ in Shopify and _Enabled_ in Craft: | Status | Shopify | Craft | -| ----------------- | -------- | -------- | +|-------------------|----------|----------| | `live` | Active | Enabled | | `shopifyDraft` | Draft | Enabled | | `shopifyArchived` | Archived | Enabled | @@ -345,7 +447,7 @@ The plugin automatically loads the relevant product when its [route](#routing) i The following element query parameters are supported, in addition to [Craft’s standard set](https://craftcms.com/docs/5.x/development/element-queries.html). > [!NOTE] -> Fields stored as JSON (like [`tags`](#tags), [`options`](#options) and [`metafields`](#metafields)) are only queryable as plain text. If you need to do advanced organization or filtering, we recommend using custom Category or Tag fields in your Product [field layout](#custom-fields). +> Fields stored as JSON (like [`tags`](#tags), [`options`](#options) and `metafields` are only queryable as plain text. If you need to do advanced organization or filtering, we recommend using custom Category or Tag fields in your Product [field layout](#custom-fields). #### `shopifyId` @@ -360,7 +462,7 @@ Filter by legacy numeric Shopify product IDs. #### `shopifyGid` -Filter by Shopify GIDs. +Filter by [Shopify GIDs](https://shopify.dev/docs/api/admin-graphql/2026-01/scalars/ID). ```twig {# Watch out—these aren't the same as element IDs! #} @@ -458,7 +560,7 @@ Filter by the vendor information from Shopify.
  • {{ product.title }}

    Available in {{ product.variants|column('title')|join(', ') }}. - + {# Similar loops for each type of nested record... #}
  • {% endfor %} @@ -474,7 +576,7 @@ You can still access `product.variants`, `product.images`, and `product.metafiel ### Product Data -Products behave just like any other element, in Twig. Once you’ve loaded a product via a [query](#querying-products) (or have a reference to one on its template), you can output its native [Shopify attributes](#native-attributes) and [custom field](#custom-fields) data. +Products behave just like any other [element](https://craftcms.com/docs/5.x/system/elements.html), in Twig. Once you’ve loaded a product via a [query](#querying-products) (or have a reference to one on its template), you can output its native [Shopify attributes](#native-attributes) and [custom field](#custom-fields) data. > [!NOTE] > Some attributes are stored as JSON, which limits nested properties’s types. As a result, dates may be slightly more difficult to work with. @@ -499,8 +601,8 @@ Products behave just like any other element, in Twig. Once you’ve loaded a pro {% endfor %} {# Images: #} -{% for image in product.images %} - {{ image.alt }} +{% for media in product.images %} + {{ media.image.altText }} {# -> Bubbly Soda #} {% endfor %} @@ -515,62 +617,119 @@ Products behave just like any other element, in Twig. Once you’ve loaded a pro ### Variants and Pricing Products don’t have a price, despite what the Shopify UI might imply—instead, every product has at least one -[Variant](https://shopify.dev/api/admin-rest/2025-07/resources/product-variant#resource-object). +[Variant](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant). -You can get an array of variant objects for a product by accessing `product.variants` or calling [`product.getVariants()`](#productgetvariants). The product element also provides convenience methods for getting the [default](#productgetdefaultvariant) and [cheapest](#productgetcheapestvariant) variants, but you can filter them however you like with Craft’s [`collect()`](https://craftcms.com/docs/5.x/reference/twig/functions.html#collect) Twig function. +You can get an array (or, more accurately, a [collection](https://craftcms.com/docs/5.x/development/collections.html)) of variant objects for a product by accessing `product.variants` or calling [`product.getVariants()`](#productgetvariants). The product element also provides convenience methods for getting the [default](#productgetdefaultvariant) and [cheapest](#productgetcheapestvariant) variants. -Unlike products, variants in Craft… +- Variants are represented by a _model_ (`craft\shopify\models\Variant`), not an element. +- Their native attributes reflect most of what is available via their corresponding [API object](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant); additional fields may be available within their `data` attribute. +- Like products, a `metafields` attribute provides access to additional store-defined data; -- …are represented (mostly) as [the API](https://shopify.dev/api/admin-rest/2025-07/resources/product-variant#resource-object) returns them; -- …the `metafields` property is accessible in addition to the API’s returned properties; -- …use Shopify’s convention of underscores in property names instead of exposing [camel-cased equivalents](#native-attributes); -- …are plain associative arrays; -- …have no methods of their own; - -Once you have a reference to a variant, you can output its properties: +Once you have a reference to a variant, you can output any of its properties: ```twig {% set defaultVariant = product.getDefaultVariant() %} -{{ defaultVariant.price|currency }} +{{ defaultVariant.price|currency(craft.shopify.store.currency) }} ``` > [!NOTE] -> The built-in [`currency`](https://craftcms.com/docs/5.x/reference/twig/filters.html#currency) Twig filter is a great way to format money values. +> The [`currency`](https://craftcms.com/docs/5.x/reference/twig/filters.html#currency) filter is provided by Craft (not the Shopify plugin). +> You must pass a three-digit [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) code to properly format a currency value. + +#### Contextual Pricing + +If you are using the [`contextualPricingCountries` setting](#settings) to sync market- or currency-specific prices from the API, you may need to reach for the appropriate `amount` and `currencyCode` within the variant’s raw data. +Both the `price` and `compareAtPrice` are available for each country, under a key following this format: + +``` +{twoLetterCountryCodeLower}ContextualPricing +``` + +Each country’s object retains the shape [described in the API](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariantContextualPricing): + +```json +{ + // Other variant properties... + + "usContextualPricing": { + "price": { + "amount": 14.99, + "currencyCode": "USD" + }, + "compareAtPrice": { + "amount": 19.99, + "currencyCode": "USD" + } + }, + "gbContextualPricing": { + "price": { + "amount": 11.99, + "currencyCode": "GBP" + }, + "compareAtPrice": { + "amount": 16.99, + "currencyCode": "GBP" + } + } +} +``` + +It’s up to you how markets are mapped to sites. +Our original pricing output example might be made dynamic, like this: + +```twig +{% set defaultVariant = product.getDefaultVariant() %} + +{# Load the current site’s "country code" from a global set: #} +{% set currentMarket = shopInfo.marketCountryCode %} + +{# Build the key according to the format, above: #} +{% set marketPrice = defaultVariant.data["#{currentMarket|lower}ContextualPricing"].price ?? null %} + +{% if marketPrice %} + {{ marketPrice.amount|currency(marketPrice.currencyCode) }} +{% else %} + {{ defaultVariant.price|currency(defaultVariant.currencyCode) }} +{% endif %} +``` ### Using Options -Options are Shopify’s way of distinguishing variants on multiple axes. +[Options](https://help.shopify.com/en/manual/products/variants) are Shopify’s way of distinguishing variants in multiple dimensions. When you add product options, Shopify typically creates a variant for each combination of their possible values. -If you want to let customers pick from options instead of directly select variants, you will need to resolve which variant a given combination points to. +If you want to let customers pick from _options_ instead of directly select from a list of _variants_, you will need to resolve which variant a given combination of options points to.
    Form ```twig
    - {# Create a hidden input to send the resolved variant ID to Shopify: #} - {{ hiddenInput('id', null, { - id: 'variant', - data: { - variants: product.variants, - }, - }) }} - - {# Create a dropdown for each set of options: #} - {% for option in product.options %} - - {% endfor %} + {# Create a hidden input to send the resolved variant ID to Shopify: #} + {{ hiddenInput('id', null, { + id: 'variant', + data: { + variants: product.variants | map(v => { + gid: v.shopifyId, + selectedOptions: v.data.selectedOptions, + }), + }, + }) }} + + {# Create a dropdown for each set of options: #} + {% for option in product.options %} + + {% endfor %} - +
    ``` @@ -580,54 +739,76 @@ If you want to let customers pick from options instead of directly select varian Script -The code below can be added to a [`{% js %}` tag](https://craftcms.com/docs/5.x/reference/twig/tags.html#js), alongside the form code. +The code below can be added to a [`{% js %}` tag](https://craftcms.com/docs/5.x/reference/twig/tags.html#js) or `