Search by

craftcms / shopify

brandonkelly

Shopify for Craft CMS

Package info

github.com/craftcms/shopify

Forum

Documentation

Type:craft-plugin

pkg:composer/craftcms/shopify

Statistics

Installs: 10 914

Dependents: 2

Suggesters: 0

Stars: 56

Open Issues: 9


README

Shopify icon

Shopify for Craft CMS

Build a content-driven storefront by synchronizing Shopify products into Craft CMS.

Important

Please review the upgrade instructions for some important changes.

Topics

  • 📦 Installation: Set up the plugin and get connected to Shopify.
  • 🗃️ Working with Products: Learn what kind of data is available and how to access it.
  • 📑 Templating: Tips and tricks for using products in Twig.
  • 🍃 Upgrading: Take advantage of new features and performance improvements.
  • 🔭 Advanced Features: Go further with your integration.

Installation

Shopify requires Craft CMS 5.10.7+.

To install the plugin, visit the Plugin Store from your Craft project, or follow these instructions.

  1. Navigate to your Craft project in a new terminal:

    cd /path/to/project
  2. Require the package with Composer:

    composer require craftcms/shopify -w
  3. In the Control Panel, go to Settings → Plugins and click the “Install” button for Shopify, or run:

    php craft plugin/install shopify

Connect to Shopify

The plugin works with Shopify’s Dev Dashboard app system, and is split into two primary parts: creating an app and performing authorization.

To install an app into a store, one of these statements must describe your account’s relationship with it:

Adding a collaborator via the Shopify admin

Caution

The new OAuth-based API connection requires that apps are created from an “organization” that has access to the Partner Dashboard. 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;
  2. In the Dev Dashboard, press Create app.
  3. In the first screen, pick an App name that identifies the integration, like Craft CMS.
  4. 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, 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:

      SHOPIFY_WEBHOOK_VERSION="2026-01"
    • Access → Scopes: The following scopes are always required:

      • read_inventory
      • read_product_listings
      • read_products

      If you plan to enable any Additional Features or Custom Scopes in the plugin settings, those will require additional scopes. Once the plugin is installed and configured, use the read-only Scopes field in Shopify → Settings as the source of truth: it always reflects the full, comma-separated string to paste here.

      [!WARNING] If you later change your Additional Features or Custom Scopes settings, you must update the scopes in your Shopify app configuration and then re-authorize the app from the Craft control panel.

    • Do not enable the Use legacy install flow as it can result in mismatched scopes during installation.

  5. Press Release to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number.
  6. Switch to the Settings screen of the new app, and copy the credentials into your .env file:
    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.
  2. The Partner Dashboard will open, with your app selected. Choose Custom distribution, press Select, then confirm in the dialog box.
  3. 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:
    SHOPIFY_HOSTNAME="my-store-name.myshopify.com"
  4. Return to the Distribution screen and press Copy link.

Identifying your store’s hostname, used when creating a distribution

You should now have a total of four SHOPIFY_* variables in your .env file:

# 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"

In the Craft control panel, navigate to Shopify → Settings to configure the plugin:

  • API Version: $SHOPIFY_WEBHOOK_VERSION
  • Client ID: $SHOPIFY_CLIENT_ID
  • Client Secret Key: $SHOPIFY_CLIENT_SECRET
  • Host Name: $SHOPIFY_HOSTNAME

Use these literal strings in the corresponding fields. As you type the $-prefixed value into an input, Craft will suggest matching variables.

Press Save to commit the settings to project config.

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!

Install in a Store

In this step, we’ll perform the authorization code grant or OAuth flow, during which Craft and Shopify negotiate a long-lived access token.

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).
  2. Select the store in Shopify’s context picker.
  3. 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.

  4. 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.)
  5. Confirm the store’s hostname and press Authorize in the dialog: Completing the OAuth flow in Craft
  6. 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.

Set up Webhooks

A new Webhooks tab will appear in the Shopify section of the control panel once you’ve completed the authorization flow.

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.

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 below for help culling unused webhook subscriptions.

Testing Webhooks

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 to create a tunnel to your local environment. DDEV makes this simple, with the ddev share command.

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!

Cleanup

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).

You can delete individual webhooks from the control panel, or by using the CLI GraphQL playground…

php craft shopify/api/query 'mutation deleteWebhook {
  webhookSubscriptionDelete(id: "gid://shopify/WebhookSubscription/123456789") {
    userErrors {
      field
      message
    }
    deletedWebhookSubscriptionId
  }
}'

…substituting a known subscription GID. Discover orphaned subscriptions using the webhookSubscriptions() query.

Upgrading

While it is technically possible to upgrade directly from 6.x to the latest 8.x version, we strongly recommend reviewing the 6.x upgrade guide, as an intermediate step.

From 7.x

Warning

Ensure the Craft queue is empty, before upgrading. Any pending sync jobs will be unable to update their status after the migration runs. In-progress bulk-synchronization operations (in Shopify) should be unaffected, unless you opt in to additional features during the upgrade.

Shopify 8.0 requires Craft CMS 5.10.7 or later, and drops support for Craft 4.x.

The most significant change for most developers will be our handling of Shopify IDs and GIDs. craft\shopify\models\Variant::$shopifyId now holds only the numeric Shopify ID (e.g. ”123456789”). The full GID (e.g. ”gid://shopify/ProductVariant/123456789”) is available via the new $shopifyGid property. Update any templates or custom code that compared or used $variant->shopifyId as a GID string.

Examples in this document reflect this change; you should no longer need to to manipulate the GID string for add-to-cart forms or other situations that required the numeric ID.

Warning

This also changes the plugin’s GraphQL API: querying a variant’s shopifyId field previously returned the full GID, and now returns the numeric ID only. Use the shopifyGid field if you need the full GID. If you have external clients or headless front-ends querying this plugin’s GraphQL API, audit them for this change.

The following methods are deprecated in favor of GID-based equivalents. Update any direct calls:

  • craft\shopify\services\BulkOperations::getBulkOperationByShopifyId() → getBulkOperationByShopifyGid()
  • craft\shopify\services\Products::deleteProductByShopifyId() → deleteProductByShopifyGid()
  • craft\shopify\services\Products::deleteShopifyDataByShopifyId() → deleteShopifyDataByShopifyGid()
  • craft\shopify\services\Products::syncProductByShopifyId() → syncProductByShopifyGid()

Warning

The shopify/shopify-api package is no longer a dependency of this plugin. If any custom code references its classes directly—like Shopify\Clients\Graphql, Shopify\Exception\ShopifyException, Shopify\Webhooks\Registry, Shopify\Auth\OAuth, or Shopify\Context—update it to use the plugin’s own equivalents (craft\shopify\clients\GraphqlClient, craft\shopify\exceptions\ShopifyApiException, craft\shopify\webhooks\WebhookRegistry, craft\shopify\auth\OAuthFlow) instead.

If you plan to enable any of the new Additional Features or the customScopes setting as part of this upgrade, see the scope re-authorization requirements described there—enabling them after the app is already authorized requires updating your Shopify app’s scopes and re-authorizing.

Tip

The changelog contains a full list of added, changed, and deprecated classes and methods.

From 6.x

These instructions were originally published with the release of 7.x, but we have adapted them here for convenience. You only need to follow these instructions if you are upgrading from 6.x directly to 8.x.

Version 7.0 was primarily concerned with Shopify API compatibility, but the new authentication mechanism means that you’ll need to re-establish the connection to Shopify using the authentication scheme described above.

Due to significant shifts in Shopify’s developer ecosystem, many of the front-end cart management techniques we have recommended (like the JS Buy SDK and Buy Button JS) are no longer viable.

Tip

We strongly recommend reviewing this same section on the 6.x branch, as there were a number of breaking changes and deprecations during the upgrade from 5.x. The changelog contains specific information about the classes and methods that have been added, removed, or deprecated.

After the upgrade, you must delete and re-create webhooks for each environment. Webhooks are registered and delivered with a specific version, and a mismatch will result in errors.

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!

Credentials

At the beginning of 2026, Shopify overhauled how “apps” are created, moving them to the new Dev Dashboard.

You should be able to create a new app, and install it using the new OAuth mechanism, without disruption to product synchronization.

Publishing and Status

Shopify has eliminated sales channels for custom apps, and therefore the publishedOnCurrentPublication field is no longer available in Product queries.

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 section.

Product Field Layouts

The product element editor has received a major overhaul. You can now choose exactly where Shopify data is placed, within the field layout.

Front-End SDKs

Shopify has retired many of its pre-built client-side frameworks, in favor of directly communicating with the generic Storefront GraphQL API. 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

Products from your Shopify store are represented in Craft as product elements, and can be found by going to Shopify → Products in the control panel.

Synchronization

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:

php craft shopify/sync/products

This adds a bulk operation to the plugin’s internal queue. Once Shopify has gathered the data, it will issue a webhook to your project, and the plugin will download and process the payload.

Going forward, your products are automatically kept in sync via webhooks. You can view a history of synchronization operations by visiting the Shopify Sync utility.

The Shopify Sync utility shows the status of each operation. One that gets stuck for more than 24 hours (for example, if its queue job was lost) is automatically marked Failed, and can be removed from the 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 Metafields, or data from third-party apps. Additional fields can be captured by listening 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:

Attribute Description Type
shopifyId The integer product ID from Shopify. Integer
shopifyGid The unique resource identifier (“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 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 associated with the product. Array
images Images (or “Media”) attached to the product in Shopify. The complete MediaImage objects are stored in Craft. Array
options 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. Yii and Twig also allow you to access some values via magic getters—any method beginning with get (like product.getDefaultVariant()) can also be treated like a property (product.defaultVariant).

Important

See the Shopify documentation on the product resource 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 emitted just before a product GraphQL query is sent.

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 to access related or nested data!

Methods

The product element has a few methods you might find useful in your templates.

Product::getVariants()

Returns a collection of variants belonging to the product. Variants are not elements (just regular models), but you can use the same dot notation to access their properties:

{% set variants = product.getVariants() %}

<select name="variantId">
  {% for variant in variants %}
    <option value="{{ variant.shopifyId }}">{{ variant.title }}option>
  {% endfor %}
select>

[!NOTICE] Like products, variants’ ids are Craft-specific identifiers. Use shopifyGid or shopifyId for the canonical Shopify values.

You can eager-load variants alongside products using the product query’s .withVariants() method.

Product::getDefaultVariant()

Shortcut for getting the first/default variant belonging to the product.

{% set products = craft.shopifyProducts
   .withVariants()
   .all() %}

<ul>
  {% for product in products %}
    {% set defaultVariant = product.getDefaultVariant() %}

    <li>
      <a href="{{ product.url }}">{{ product.title }}a>
      <span>{{ defaultVariant.price|currency }}span>
    li>
  {% endfor %}
ul>

Product::getCheapestVariant()

Shortcut for getting the lowest-priced variant belonging to the product.

{% set cheapestVariant = product.getCheapestVariant() %}

Starting at {{ cheapestVariant.price|currency }}!

Note that this does not factor in contextual pricing.

Product::getShopifyUrl()

{# Get a link to the product’s page on Shopify: #}
<a href="{{ product.getShopifyUrl() }}">View on our storea>

{# Link to a product with a specific variant pre-selected: #}
<a href="{{ product.getShopifyUrl({ variant: variant.id }) }}">Buy nowa>

This has limited utility if you are displaying products on-site (rather than linking back to a Shopify storefront). To get the URL of a product within your Craft project, use product.url.

Product::getShopifyEditUrl()

For administrators, you can even link directly to the Shopify admin:

{# Assuming you’ve created a custom group for Shopify admin: #}
{% if currentUser and currentUser.isInGroup('clerks') %}
  <a href="{{ product.getShopifyEditUrl() }}">Edit product on Shopifya>
{% endif %}

Custom Fields

Products synchronized from Shopify have a dedicated field layout, which means they support Craft’s full array of content tools. 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.

Fields are accessible from any product element, by their handle:

{# Native properties: #}
<h2>{{ product.title }}h2>
<span class="price">{{ product.price|currency }}span>

{# Custom relational field: #}
<ul class="support">
  {% for article in product.relatedHelpArticles.all() %}
    <li>{{ article.getLink() }}li>
  {% endfor %}
ul>

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. A URI format that emulates Shopify’s default would look something like this:

products/{handle}

Any native attribute, custom field 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 instead of product.url in your templates.

Product Status

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

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
disabled Any Disabled

This is the default behavior when querying for products, but you can pass one of the custom Status options above to the .status() param to override it.

Querying Products

Products can be queried like any other element type in Craft.

A new query begins with the craft.shopifyProducts factory function:

{% set products = craft.shopifyProducts.all() %}

The plugin automatically loads the relevant product when its route is requested, and makes a product variable available in the template. You only need to query for products when when they are displayed outside of this context. Product fields also return product queries.

Query Parameters

The following element query parameters are supported, in addition to Craft’s standard set.

Note

Fields stored as JSON (like tags, 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.

shopifyId

Filter by legacy numeric Shopify product IDs.

{# Watch out—these aren't the same as element IDs! #}
{% set singleProduct = craft.shopifyProducts
  .shopifyId(123456789)
  .one() %}

shopifyGid

Filter by Shopify GIDs.

{# Watch out! These aren’t the same as element IDs or Shopify IDs. #}
{% set singleProduct = craft.shopifyProducts
  .shopifyGid('gid://shopify/Product/123456789')
  .one() %}

This is equivalent to .shopifyId(123456789), but may be simpler if you are combining data from client-side queries.

shopifyStatus

Directly query against the product’s status in Shopify.

{% set archivedProducts = craft.shopifyProducts
  .shopifyStatus('archived')
  .all() %}

Use the regular .status() param if you'd prefer to query against the synthesized product status values.

Warning

Note that .shopifyStatus() does not override conditions applied by the .status() param (including the defaults). You may need to call .status(null) to unset them, or use .status('shopifyDraft'), directly.

handle

Query by the product’s handle, in Shopify.

{% set product = craft.shopifyProducts
  .handle('worlds-tallest-socks')
  .all() %}

Warning

This is not a reliable means to fetch a specific product, as the value may change during a synchronization. If you want to store a permanent reference to a product, consider using the Shopify product field to relate it by element ID.

productType

Find products by their “type” in Shopify.

{% set upSells = craft.shopifyProducts
  .productType(['apparel', 'accessories'])
  .all() %}

tags

Tags are stored as a JSON array, which may complicate direct comparisons. You may see better results using the .search() param.

{# Find products whose tags include the term in any position, with variations on casing: #}
{% set clogs = craft.shopifyProducts
  .tags(['*clog*', '*Clog*'])
  .all() %}

options

Options are stored as a JSON array, which may complicate direct comparisons. You may see better results using the .search() param.

{# Find products with an option value like "Large": #}
{% set clogs = craft.shopifyProducts
  .search('*Large*')
  .all() %}

vendor

Filter by the vendor information from Shopify.

{# Find products with a vendor matching either option: #}
{% set fancyBags = craft.shopifyProducts
  .vendor(['Louis Vuitton', 'Jansport'])
  .all() %}

Eager-loading

Variants (ProductVariants), images (MediaImages), and meta fields (Metafields) attached to product elements are not elements themselves, and must be explicitly eager-loaded to avoid performance issues when displaying data in a loop:

{% set products = craft.shopifyProducts()
   .withVariants()
   .withImages()
   .withMetafields()
   .all() %}

<ul>
  {% for product in products %}
    <li>
      <h2>{{ product.title }}h2>
      Available in {{ product.variants|column('title')|join(', ') }}.

      {# Similar loops for each type of nested record... #}
    li>
  {% endfor %}
ul>

Tip

The shorthand .withAll() is a future-proof means of eager-loading each additional type of nested record.

You can still access product.variants, product.images, and product.metafields without eager-loading—but it may result in an additional query for each kind of content. Once you’ve retrieved variants, for example, they are memoized on the product element instance for the duration of the request.

GraphQL

Product elements are also exposed via Craft’s GraphQL API. You can fetch products (and any content added via custom fields) using the shopifyProducts() query:

query PowerTools {
  shopifyProducts(productType: "powertools") {
    # Native Product element properties:
    id
    title
    shopifyStatus
    images {
      image {
        url
        altText
        width
        height
      }
    }

    # Custom fields, from the field layout in Craft:
    ... on ShopifyProduct {
      brandName
      brandFamily
    }
  }
}

Products can be retrieved one at a time with the shopifyProduct (singular) query. You might use this in a headless front-end to resolve a product by its slug, based on your routing:

query OneProductBySlug($slug: [String]) {
  shopifyProduct(slug: $slug) {
    id
    title
    # ...
  }
}

# Variables:
# {
#   slug: Astro.params.slug
# }

It’s important to note that the GraphQL schema is not the same as directly accessing the Shopify API, and that the built-in documentation only reflects what is accessible via Craft. You’ll encounter many familiar objects, but only a subset of the types and fields are available. Only the data retrieved during synchronization (plus your custom fields and other native element properties) will be present in the API.

Arguments are also radically different: Shopify’s filtering is primarily accomplished via the single, generic query param. Craft uses dedicated field names and types for argument inputs, so the above single- and multi-product queries accept any combination of criteria, including references to custom fields.

Tip

Use the GraphiQL IDE in Craft’s control panel to explore the self-documenting API!

Extending

GraphQL is inherently strictly “typed,” which means that the arbitrary shape of Products’ data attribute is not selectable or navigable.

If you wish to expose additional fields you have synchronized from the API, they must be added as Craft builds the product element schema:

use craft\base\Event;
use craft\events\DefineGqlTypeFieldsEvent;
use craft\gql\TypeManager;
use craft\shopify\elements\Product;
use GraphQL\Type\Definition\Type;

Event::on(
    TypeManager::class,
    TypeManager::EVENT_DEFINE_GQL_TYPE_FIELDS,
    function(DefineGqlTypeFieldsEvent $event) {
        // Exit early unless it’s the “type” definition we want to modify:
        if ($event->typeName !== Product::GQL_TYPE_NAME) {
            return;
        }

        // Register a new field that can resolve the supplemental `data`:
        $event->fields['shopifyCategory'] = [
            'name' => 'shopifyCategory',
            'type' => Type::string(),
            'description' => 'The Shopify “Standard Product Taxonomy” name.',
            'resolve' => function(Product $source) {
                return $source->getData()['category']['name'] ?? null;
            },
        ];
    }
)

As long as you keep your “resolver” in sync with your additional selections, you should be able to pass through those values. The same strategy can be used to decorate other built-in types for images, metafields, options, and variants. You can alter multiple types in a single EVENT_DEFINE_GQL_TYPE_FIELDS handler:

if ($event->typeName === \craft\shopify\elements\Product::GQL_TYPE_NAME) {
    // Manipulate product element fields...
}

if ($event->typeName === \craft\shopify\gql\types\Image::getName()) {
    // Manipulate fields on "image" objects...
}

Warning

The fields we’ve added are not dynamically fetched from Shopify at runtime! This method just exposes additional fields that have already been synchronized from Shopify.

Templating

Product Data

Products behave just like any other element, in Twig. Once you’ve loaded a product via a query (or have a reference to one on its template), you can output its native Shopify attributes and custom field 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.

{# Standard element title: #}
{{ product.title }}
  {# -> Root Beer #}

{# Shopify HTML content: #}
{{ product.descriptionHtml|raw }}
  {# -> 

...

#}
{# Tags, as list: #} {{ product.tags|join(', ') }} {# -> sweet, spicy, herbal #} {# Tags, as filter links: #} {% for tag in tags %} <a href="{{ siteUrl('products', { tag: tag }) }}">{{ tag|title }}a> {# -> Herbal #} {% endfor %} {# Images: #} {% for media in product.images %} <img src="{{ media.image.url }}" alt="{{ media.image.altText }}"> {# -> Bubbly Soda #} {% endfor %} {# Variants: #} <select name="variantId"> {% for variant in product.variants %} <option value="{{ variant.id }}">{{ variant.title }} ({{ variant.price|currency }})option> {% endfor %} select>

Variants and Pricing

Products don’t have a price, despite what the Shopify UI might imply—instead, every product has at least one Variant.

You can get an array (or, more accurately, a collection) of variant objects for a product by accessing product.variants or calling product.getVariants(). The product element also provides convenience methods for getting the default and cheapest variants.

  • 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; additional fields may be available within their data attribute.
  • Like products, a metafields attribute provides access to additional store-defined data;

Once you have a reference to a variant, you can output any of its properties:

{% set defaultVariant = product.getDefaultVariant() %}

{{ defaultVariant.price|currency(craft.shopify.store.currency) }}

Note

The currency filter is provided by Craft (not the Shopify plugin). You must pass a three-digit ISO 4217 code to properly format a currency value.

Contextual Pricing

If you are using the contextualPricingCountries setting 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:

{
  // 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:

{% 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 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 from a list of variants, you will need to resolve which variant a given combination of options points to.

Form
<form id="add-to-cart" method="post" action="{{ craft.shopify.store.getUrl('cart/add') }}">
    {# 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.shopifyGid,
                selectedOptions: v.data.selectedOptions,
            }),
        },
    }) }}

    {# Create a dropdown for each set of options: #}
    {% for option in product.options %}
        <label>
            {{ option.name }}
            {# The dropdown is tagged with the option’s `name`, so we can match it with selections, later: #}
            <select data-option="{{ option.name }}">
                {% for val in option.values %}
                    <option value="{{ val }}">{{ val }}option>
                {% endfor %}
            select>
        label>
    {% endfor %}

    <button id="submit">Add to Cartbutton>
form>
Script

The code below can be added to a {% js %} tag or