Every few months someone asks in a dev forum: "Is there a plugin to migrate
from Shopify to WooCommerce?" The answers are always the same — outdated paid
tools, CSV workarounds, or "do it manually."
I decided to build the obvious thing. This is the technical walkthrough of how
it works, what broke, and the architectural decisions I'd make differently.
The plugin is Laterreta Migrator for Shopify
— free on WordPress.org, Pro version available.
Why the Storefront API and not the Admin API?
Shopify has two main APIs:
- Admin API — full access, requires merchant approval per store, OAuth flow
- Storefront API — read-only product/collection data, simple token auth
For a migration tool the Storefront API is the right choice. The user just
creates a Storefront API token in their Shopify app settings and pastes it into
the plugin. No OAuth dance, no app approval process, no scopes negotiation.
The trade-off: no access to order history or customer data (that requires the
Admin API). For product migration, it's everything you need.
Fetching products with cursor pagination
The Storefront API uses GraphQL with cursor-based pagination. You request a
page, get a cursor for the last item, and pass it to the next request.
php
function lmsf_fetch_all_products(): array {
$products = [];
$after = null;
$gql = 'query P($first:Int!, $after:String) {
products(first:$first, after:$after) {
pageInfo { hasNextPage endCursor }
edges { node {
id title handle descriptionHtml
priceRange { minVariantPrice { amount currencyCode } }
variants(first:100) { edges { node {
id title price { amount }
availableForSale
selectedOptions { name value }
}}}
options { name values }
collections(first:10) { edges { node { handle } }}
}}
}
}';
do {
$data = lmsf_shopify_query( $gql, [ 'first' => 50, 'after' => $after ] );
$page = $data['products'] ?? [];
foreach ( $page['edges'] ?? [] as $edge ) {
$products[] = $edge['node'];
}
$hasNext = $page['pageInfo']['hasNextPage'] ?? false;
$after = $page['pageInfo']['endCursor'] ?? null;
} while ( $hasNext );
return $products;
}
lmsf_shopify_query() wraps wp_remote_post() to the
/api/2025-07/graphql.json endpoint with the Bearer token. The do...while
loop runs until hasNextPage is false — handles stores with thousands of
products without loading everything into memory at once.
The Default Title trap
Here's the first thing that will break your import if you're not careful.
In Shopify, every product has at least one variant — even products with no
real options. A simple t-shirt with no size or color options still has a single
variant called Default Title with option Title: Default Title.
If you treat every product with variants as a WooCommerce variable product,
you'll end up with simple products that have one meaningless attribute and one
variation. It breaks pricing, looks wrong in the storefront, and confuses
WooCommerce's internal sync.
The fix is to detect the pattern before deciding product type:
$variants = $product['variants']['edges'] ?? [];
$variant_nodes = array_map( fn( $e ) => $e['node'], $variants );
$is_simple = count( $variant_nodes ) === 1
&& ( $variant_nodes[0]['title'] ?? '' ) === 'Default Title';
if ( $is_simple ) {
$wc = new WC_Product_Simple();
$wc->set_regular_price( $variant_nodes[0]['price']['amount'] ?? '0' );
} else {
$wc = new WC_Product_Variable();
// ... map options and variations
}
Simple once you know it's there. It affects every simple product in a Shopify
store, so getting it wrong breaks the majority of imports.
Mapping variants to WooCommerce variations
For variable products, Shopify options map to WooCommerce attributes and
selectedOptions on each variant map to the variation's attribute values.
// Register attributes on the parent product
$attributes = [];
foreach ( $product['options'] as $option ) {
$attr = new WC_Product_Attribute();
$attr->set_name( $option['name'] );
$attr->set_options( $option['values'] );
$attr->set_visible( true );
$attr->set_variation( true );
$attributes[] = $attr;
}
$wc->set_attributes( $attributes );
$wc->save();
// Create each variation
foreach ( $variant_nodes as $variant ) {
$var = new WC_Product_Variation();
$var->set_parent_id( $wc->get_id() );
$var->set_regular_price( $variant['price']['amount'] ?? '0' );
$var->set_stock_status(
$variant['availableForSale'] ? 'instock' : 'outofstock'
);
$var->set_attributes( array_combine(
array_map(
fn( $o ) => 'attribute_' . sanitize_title( $o['name'] ),
$variant['selectedOptions']
),
array_map( fn( $o ) => $o['value'], $variant['selectedOptions'] )
) );
$var->save();
update_post_meta( $var->get_id(), '_shopify_variant_id', $variant['id'] );
}
WC_Product_Variable::sync( $wc->get_id() );
The WC_Product_Variable::sync() call at the end is important — it
recalculates the price range displayed on the product page from all child
variations.
Watch out: option names with special characters. WooCommerce slugifies
attribute names via sanitize_title(), so an option called "Talla/Size"
becomes talla-size as a slug. The variation matcher compares slugified values,
so make sure you slugify consistently on both sides.
Detecting already-imported products
Stores rarely migrate in one clean run. Someone will test, abort, fix something,
and run again. You need to handle duplicates gracefully.
The solution: store the Shopify product ID as post meta on every imported
product, then check before creating:
$existing = get_posts( [
'post_type' => 'product',
'post_status' => 'any',
'posts_per_page' => 1,
'meta_key' => '_shopify_id',
'meta_value' => $product['id'],
'fields' => 'ids',
] );
if ( ! empty( $existing ) ) {
if ( $update_mode ) {
// Update prices, stock, description
$wc = wc_get_product( $existing[0] );
} else {
// Skip
continue;
}
} else {
// Create new product
$wc = $is_simple
? new WC_Product_Simple()
: new WC_Product_Variable();
}
The update_mode flag is a settings option — the user can choose whether
re-running the migration skips or updates existing products.
Multilingual translations via metafields
Many Shopify stores store EN/ES/DE/FR translations in product metafields with
a translations namespace. The GraphQL query can fetch multiple metafields
in a single request using aliases:
title_en: metafield(namespace:"translations", key:"title_en") { value }
title_de: metafield(namespace:"translations", key:"title_de") { value }
title_fr: metafield(namespace:"translations", key:"title_fr") { value }
description_en: metafield(namespace:"translations", key:"description_html_en") { value }
These get stored as WooCommerce product meta (_lmsf_title_en, etc.) and can
be consumed by any frontend or translation plugin.
A hook-based freemium architecture for WP.org compliance
WordPress.org has a strict policy against trialware: you cannot ship locked
features to their repository. The free plugin must work completely as-is.
No if (is_pro()) {} wrappers. Pro-only code must be physically absent
from the free ZIP.
The solution is to use WordPress hooks as a bridge.
The main plugin fires filters and actions at extension points, but registers no
callbacks for them itself:
// In the products GraphQL query:
$extra_fields = apply_filters( 'lmsf_graphql_product_extra_fields', '' );
// After a product is imported:
do_action( 'lmsf_after_product_imported', $postId, $product );
In the free build, apply_filters() returns the empty string default and
do_action() fires with no listeners. In the Pro build, extra files are
included that register callbacks:
// Pro: images.php
add_filter( 'lmsf_graphql_product_extra_fields', function( string $extra ): string {
return $extra . "\nimages(first:10) { edges { node { url altText } } }";
});
add_action( 'lmsf_after_product_imported', function( int $postId, array $product ): void {
// Download images from Shopify CDN and attach to the product
lmsf_pro_import_images( $postId, $product );
}, 10, 2 );
Two separate ZIPs are built from the same source. The free one goes to WP.org
SVN. The Pro one goes to the product download on our site. Clean compliance,
no duplicated logic, no runtime license checks in the free build.
The same pattern handles scheduled sync, custom metafields mapping, tag →
taxonomy mapping, and any future Pro feature.
Scheduled sync
Once products are imported, prices and stock can drift if the merchant keeps
selling on Shopify during the transition. The Pro version adds a WP-Cron job
that refetches products from Shopify on a configurable schedule (hourly/daily/
weekly) and updates only what changed:
function lmsf_sync_one_product( array $shopify ): void {
// Find the WC product by _shopify_id meta
$posts = get_posts([
'post_type' => 'product',
'meta_key' => '_shopify_id',
'meta_value' => $shopify['id'],
'fields' => 'ids',
]);
if ( empty( $posts ) ) return;
$wc = wc_get_product( $posts[0] );
// Detect sale price via compareAtPrice
foreach ( $shopify['variants']['edges'] as $edge ) {
$v = $edge['node'];
$price = $v['price']['amount'] ?? '0';
$compare = $v['compareAtPrice']['amount'] ?? '0';
$is_on_sale = $compare > 0 && $compare > $price;
// Update variation...
}
// Unpublish if unavailable in Shopify
if ( ! $shopify['availableForSale'] ) {
wp_update_post([
'ID' => $posts[0],
'post_status' => 'draft',
]);
}
}
What I'd do differently
Skip the "quick script" phase. I started with a one-off script for a
client, then refactored it into a proper plugin. The incremental rewrite cost
more time than building the features themselves.
Design the hook bridge from day one. Adding extension points to existing
code is always messier than designing them upfront. I had to touch the core
query-building code multiple times as I added Pro features.
Test with weird stores early. The edge cases (Default Title variants,
special characters in option names, metafields with null values) only showed
up when I tested against real stores with real data. Synthetic test data masks
most of the interesting bugs.
The plugin is free on WordPress.org:
👉 Laterreta Migrator for Shopify
If you've built something in the WP/WooCommerce ecosystem and have thoughts
on the hook architecture or the freemium compliance approach, I'd love to
hear them in the comments.
Top comments (0)