<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ROBERTO ANGUITA MARTIN</title>
    <description>The latest articles on DEV Community by ROBERTO ANGUITA MARTIN (@roberto_anguitamartin_0b).</description>
    <link>https://dev.to/roberto_anguitamartin_0b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3903131%2F6018dead-ac29-415c-870f-4e4fc121c303.jpg</url>
      <title>DEV Community: ROBERTO ANGUITA MARTIN</title>
      <link>https://dev.to/roberto_anguitamartin_0b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/roberto_anguitamartin_0b"/>
    <language>en</language>
    <item>
      <title>The WordPress.org freemium trap: how to ship a Pro plugin without getting suspended</title>
      <dc:creator>ROBERTO ANGUITA MARTIN</dc:creator>
      <pubDate>Tue, 19 May 2026 21:27:23 +0000</pubDate>
      <link>https://dev.to/roberto_anguitamartin_0b/the-wordpressorg-freemium-trap-how-to-ship-a-pro-plugin-without-getting-suspended-4543</link>
      <guid>https://dev.to/roberto_anguitamartin_0b/the-wordpressorg-freemium-trap-how-to-ship-a-pro-plugin-without-getting-suspended-4543</guid>
      <description>&lt;p&gt;If you've ever thought about building a freemium WordPress plugin and&lt;br&gt;
distributing the free version through WordPress.org, there's a compliance&lt;br&gt;
rule that will catch you off guard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot ship locked features.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not locked behind a license check. Not hidden behind an &lt;code&gt;if (is_pro())&lt;/code&gt;.&lt;br&gt;
Not greyed out with a tooltip saying "upgrade to unlock." The WordPress.org&lt;br&gt;
guidelines call this &lt;em&gt;trialware&lt;/em&gt;, and plugins that do it get suspended.&lt;/p&gt;

&lt;p&gt;This is the architecture I built to solve it — for&lt;br&gt;
&lt;a href="https://wordpress.org/plugins/laterreta-migrator-for-shopify/" rel="noopener noreferrer"&gt;Laterreta Migrator for Shopify&lt;/a&gt;,&lt;br&gt;
a plugin that migrates Shopify stores to WooCommerce.&lt;/p&gt;


&lt;h2&gt;
  
  
  What the guidelines actually say
&lt;/h2&gt;

&lt;p&gt;From the &lt;a href="https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/" rel="noopener noreferrer"&gt;WordPress.org plugin guidelines&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Plugins may not contain functionality that is hidden or disabled and&lt;br&gt;
waiting to be unlocked by a purchase or license.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The key word is &lt;em&gt;contain&lt;/em&gt;. The code cannot be in the plugin at all — not&lt;br&gt;
even dormant, not even behind a flag.&lt;/p&gt;

&lt;p&gt;This rules out the most common freemium patterns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Not allowed on WP.org — code is present but locked&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;has_valid_license&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;download_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ❌ Also not allowed — feature exists but is gated&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;download_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nf"&gt;is_pro&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;show_upgrade_notice&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// actual code...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of these put Pro code inside the free ZIP. That's trialware.&lt;/p&gt;

&lt;p&gt;The solution: hooks as a bridge&lt;br&gt;
WordPress's hook system (add_filter / add_action / apply_filters /&lt;br&gt;
do_action) gives you a clean way to make the free plugin extensible&lt;br&gt;
without including the extension code.&lt;/p&gt;

&lt;p&gt;The idea: the main plugin fires hooks at every point where Pro behaviour&lt;br&gt;
could happen. In the free build, those hooks have no callbacks — they do&lt;br&gt;
nothing. The Pro build ships extra files that register callbacks on those&lt;br&gt;
hooks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the main plugin (ships in BOTH free and Pro)&lt;/span&gt;
&lt;span class="c1"&gt;// Pro: add image fields to the GraphQL query&lt;/span&gt;
&lt;span class="nv"&gt;$image_fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_graphql_image_fields'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Pro: do something after a product is imported&lt;/span&gt;
&lt;span class="nf"&gt;do_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_after_product_imported'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;In&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;apply_filters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;
&lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;do_action&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;fires&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zero&lt;/span&gt; &lt;span class="n"&gt;listeners&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nc"&gt;No&lt;/span&gt; &lt;span class="nc"&gt;Pro&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="n"&gt;because&lt;/span&gt;
&lt;span class="n"&gt;there&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="nc"&gt;Pro&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Pro build, extra files are included that register callbacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pro only: includes/pro/images.php  (absent from free ZIP)&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_graphql_image_fields'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;images(first:10){ edges{ node{ url altText } } }"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_after_product_imported'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;lmsf_download_and_attach_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'images'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'edges'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Pro file is physically absent from the free ZIP. There's nothing&lt;br&gt;
to unlock — the feature simply doesn't exist in that build.&lt;/p&gt;

&lt;p&gt;Two ZIPs from the same source&lt;br&gt;
The build script produces two completely different ZIPs from the same&lt;br&gt;
codebase:&lt;/p&gt;

&lt;p&gt;./build.sh          # Pro ZIP — includes/pro/ copied in&lt;br&gt;
./build.sh --free   # Free ZIP — includes/pro/ deleted before packaging&lt;br&gt;
The free ZIP goes to WordPress.org SVN. The Pro ZIP goes to the product&lt;br&gt;
download on our site. Same source, different outputs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Simplified build logic&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;$FREE_BUILD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;rm&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes/pro"&lt;/span&gt;
    &lt;span class="n"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LICENSE_STUB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes/license.php"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PLUGIN_DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes/pro"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes/pro"&lt;/span&gt;
    &lt;span class="n"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LICENSE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes/license.php"&lt;/span&gt;
&lt;span class="n"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Pro feature follows the same pattern&lt;br&gt;
Once the hook bridge is in place, adding Pro features is always the same&lt;br&gt;
three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a hook in the main plugin at the right extension point
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Main plugin: migration query builder&lt;/span&gt;
&lt;span class="nv"&gt;$extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_graphql_product_extra_fields'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$gql&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"query P { products { edges { node { id title &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$extra&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; } } } }"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Create a file in includes/pro/ that registers a callback
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// includes/pro/tags.php&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_graphql_product_extra_fields'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$extra&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;tags"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_after_product_imported'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;lmsf_tags_apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Build — the Pro file is included, the free file is not&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Current Pro features and their hooks:&lt;/p&gt;

&lt;p&gt;Feature Filter (adds fields)    Action (saves data)&lt;br&gt;
Image download  lmsf_graphql_image_fields   lmsf_after_product_imported&lt;br&gt;
Custom metafields   lmsf_graphql_product_extra_fields   lmsf_after_product_imported&lt;br&gt;
Tag mapping lmsf_graphql_product_extra_fields   lmsf_after_product_imported&lt;br&gt;
Scheduled sync  lmsf_sync_extra_product_fields  lmsf_sync_product_extra&lt;br&gt;
The license stub pattern&lt;br&gt;
The Pro build includes a license validation system. The free build needs&lt;br&gt;
a license.php file too — but without any external calls or validation&lt;br&gt;
logic.&lt;/p&gt;

&lt;p&gt;I maintain two files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;includes&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;license&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="nc"&gt;Pro&lt;/span&gt; &lt;span class="n"&gt;license&lt;/span&gt; &lt;span class="nf"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;LM4WC&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;includes&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;license&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;stub&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;stub&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="n"&gt;defaults&lt;/span&gt;
&lt;span class="c1"&gt;// license-stub.php — ships in the free build&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;lmsf_get_license_tier&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;lmsf_get_license_key&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;lmsf_is_license_valid&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;lmsf_upgrade_url&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;       &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'https://www.laterretagames.com/migrate-from-shopify-to-woocommerce/'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;The&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="n"&gt;swaps&lt;/span&gt; &lt;span class="n"&gt;them&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;$FREE_BUILD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LICENSE_STUB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes/license.php"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LICENSE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAGE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/includes/license.php"&lt;/span&gt;
&lt;span class="n"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same function signatures, different implementations. The main plugin&lt;br&gt;
calls lmsf_upgrade_url() to render the "Get Pro" button — it works in&lt;br&gt;
both builds without any conditional.&lt;/p&gt;

&lt;p&gt;What this architecture can't do&lt;br&gt;
Discovery is harder. A user on the free build can't see a list of&lt;br&gt;
Pro features inside the plugin unless you explicitly build a "What's in&lt;br&gt;
Pro" section. The features are absent, not locked — so there's nothing&lt;br&gt;
to surface automatically.&lt;/p&gt;

&lt;p&gt;Solution: Build static Pro feature cards into the free dashboard.&lt;br&gt;
Show exactly what the migration didn't do because Pro wasn't active.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Free dashboard — always visible&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'LMSF_FREE_VERSION'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;LMSF_FREE_VERSION&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Show cards: images not downloaded, sync not active, tags not mapped&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deactivation hooks need a constant. register_deactivation_hook()&lt;br&gt;
requires the main plugin file path. If Pro files reference it, they need&lt;br&gt;
a constant defined in the main plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Main plugin&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'LMSF_FILE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;__FILE__&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Pro: scheduler.php&lt;/span&gt;
&lt;span class="nf"&gt;register_deactivation_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="no"&gt;LMSF_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'lmsf_clear_cron_schedule'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is it worth the complexity?&lt;br&gt;
Yes — for three reasons:&lt;/p&gt;

&lt;p&gt;WP.org compliance is non-negotiable. Getting suspended means&lt;br&gt;
losing organic discovery permanently. The hook architecture is&lt;br&gt;
the only clean way to stay compliant.&lt;br&gt;
The architecture is actually better code. Hooks as extension&lt;br&gt;
points is how WordPress itself is designed. Pro features are properly&lt;br&gt;
isolated — a bug in images.php can't affect the migration core.&lt;br&gt;
It scales. Every new Pro feature is a new file in includes/pro/.&lt;br&gt;
No changes to the main plugin required beyond adding an extension point.&lt;br&gt;
The free plugin on WP.org:&lt;br&gt;
👉 &lt;a href="https://wordpress.org/plugins/laterreta-migrator-for-shopify/" rel="noopener noreferrer"&gt;Laterreta Migrator for Shopify&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building a freemium WP plugin and have questions about the&lt;br&gt;
compliance side, happy to go into more detail in the comments.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I built a Shopify-to-WooCommerce migration plugin using the Storefront GraphQL API</title>
      <dc:creator>ROBERTO ANGUITA MARTIN</dc:creator>
      <pubDate>Thu, 07 May 2026 16:37:29 +0000</pubDate>
      <link>https://dev.to/roberto_anguitamartin_0b/how-i-built-a-shopify-to-woocommerce-migration-plugin-using-the-storefront-graphql-api-1c58</link>
      <guid>https://dev.to/roberto_anguitamartin_0b/how-i-built-a-shopify-to-woocommerce-migration-plugin-using-the-storefront-graphql-api-1c58</guid>
      <description>&lt;p&gt;Every few months someone asks in a dev forum: &lt;em&gt;"Is there a plugin to migrate&lt;br&gt;
from Shopify to WooCommerce?"&lt;/em&gt; The answers are always the same — outdated paid&lt;br&gt;
tools, CSV workarounds, or "do it manually."&lt;/p&gt;

&lt;p&gt;I decided to build the obvious thing. This is the technical walkthrough of how&lt;br&gt;
it works, what broke, and the architectural decisions I'd make differently.&lt;/p&gt;

&lt;p&gt;The plugin is &lt;a href="https://wordpress.org/plugins/laterreta-migrator-for-shopify/" rel="noopener noreferrer"&gt;Laterreta Migrator for Shopify&lt;/a&gt;&lt;br&gt;
— free on WordPress.org, Pro version available.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Storefront API and not the Admin API?
&lt;/h2&gt;

&lt;p&gt;Shopify has two main APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Admin API&lt;/strong&gt; — full access, requires merchant approval per store, OAuth flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storefront API&lt;/strong&gt; — read-only product/collection data, simple token auth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a migration tool the Storefront API is the right choice. The user just&lt;br&gt;
creates a Storefront API token in their Shopify app settings and pastes it into&lt;br&gt;
the plugin. No OAuth dance, no app approval process, no scopes negotiation.&lt;br&gt;
The trade-off: no access to order history or customer data (that requires the&lt;br&gt;
Admin API). For product migration, it's everything you need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fetching products with cursor pagination
&lt;/h2&gt;

&lt;p&gt;The Storefront API uses GraphQL with cursor-based pagination. You request a&lt;br&gt;
page, get a cursor for the last item, and pass it to the next request.&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
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' =&amp;gt; 50, 'after' =&amp;gt; $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 ) =&amp;gt; $e['node'], $variants );

$is_simple = count( $variant_nodes ) === 1
    &amp;amp;&amp;amp; ( $variant_nodes[0]['title'] ?? '' ) === 'Default Title';

if ( $is_simple ) {
    $wc = new WC_Product_Simple();
    $wc-&amp;gt;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-&amp;gt;set_name( $option['name'] );
    $attr-&amp;gt;set_options( $option['values'] );
    $attr-&amp;gt;set_visible( true );
    $attr-&amp;gt;set_variation( true );
    $attributes[] = $attr;
}
$wc-&amp;gt;set_attributes( $attributes );
$wc-&amp;gt;save();

// Create each variation
foreach ( $variant_nodes as $variant ) {
    $var = new WC_Product_Variation();
    $var-&amp;gt;set_parent_id( $wc-&amp;gt;get_id() );
    $var-&amp;gt;set_regular_price( $variant['price']['amount'] ?? '0' );
    $var-&amp;gt;set_stock_status(
        $variant['availableForSale'] ? 'instock' : 'outofstock'
    );
    $var-&amp;gt;set_attributes( array_combine(
        array_map(
            fn( $o ) =&amp;gt; 'attribute_' . sanitize_title( $o['name'] ),
            $variant['selectedOptions']
        ),
        array_map( fn( $o ) =&amp;gt; $o['value'], $variant['selectedOptions'] )
    ) );
    $var-&amp;gt;save();
    update_post_meta( $var-&amp;gt;get_id(), '_shopify_variant_id', $variant['id'] );
}

WC_Product_Variable::sync( $wc-&amp;gt;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'      =&amp;gt; 'product',
    'post_status'    =&amp;gt; 'any',
    'posts_per_page' =&amp;gt; 1,
    'meta_key'       =&amp;gt; '_shopify_id',
    'meta_value'     =&amp;gt; $product['id'],
    'fields'         =&amp;gt; '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'  =&amp;gt; 'product',
        'meta_key'   =&amp;gt; '_shopify_id',
        'meta_value' =&amp;gt; $shopify['id'],
        'fields'     =&amp;gt; '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 &amp;gt; 0 &amp;amp;&amp;amp; $compare &amp;gt; $price;

        // Update variation...
    }

    // Unpublish if unavailable in Shopify
    if ( ! $shopify['availableForSale'] ) {
        wp_update_post([
            'ID'          =&amp;gt; $posts[0],
            'post_status' =&amp;gt; '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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>wordpress</category>
      <category>shopify</category>
      <category>graphql</category>
      <category>php</category>
    </item>
  </channel>
</rss>
