Skip to content

Shopify ​

The Shopify module imports products and collections from one or more Shopify stores, renders them as Evolve pages, and hands the cart to Shopify's checkout. This page covers the parts that need attention on a running site: stores and their API tokens, the product status per store, and the cleanup of deleted products.

  • Module key shopify
  • Default on
  • Switch EVOLVE_MODULE_SHOPIFY, see Modules
  • Role access evolve.roles.modules.shopify

The module publishes shopify.components (block definitions, Livewire components and views) and shopify.assets (the cart store script to public/vendor). HEALTH_CHECK_SHOPIFY=true adds an hourly API check to Health Checks.

Stores and tokens ​

Stores are managed in the panel under Shopify › Stores, not in the config. Each store has:

FieldUsed for
Storefront access tokenthe product and collection import, the cart. The Storefront API only returns products published to the store's sales channel.
Admin access tokencurrency detection, the Google Shopping feed and, since 2.12, the product status. Needs the read_products scope.

One store is the master: products are created from its feed, and its status decides whether a product is shown at all. Further stores are mirrors (for example one per country, mapped to a region). They add store specific data (price, variants, availability) to the products the master defines.

Admin token scope

The status sync and the Google Shopping feed need nothing beyond read_products. Publication is taken from the store's own Storefront feed, so read_publications is not needed. A store without an Admin token keeps working as before; its products are just never classified beyond unpublished.

Configuration ​

Config keyEnv variableDefaultEffect
shopify.api-versionSHOPIFY_API_VERSION2025-10Storefront and Admin API version.
shopify.admin-api-versionSHOPIFY_ADMIN_API_VERSIONthe API versionAdmin API version, when it must differ.
shopify.auto-importSHOPIFY_AUTO_IMPORTunsetScheduled import interval: everyFifteenMinutes, everyThirtyMinutes, hourly, daily or weekly. Unset disables it.
shopify.purge-deleted-after-days 2.12SHOPIFY_PURGE_DELETED_AFTER_DAYSunsetRetention window for purging deleted products. When set, the purge runs daily.
shopify.allow-blocksSHOPIFY_ALLOW_BLOCKStrueContent blocks on product pages.
shopify.allow-notesSHOPIFY_ALLOW_NOTESfalseA note field in the cart.
shopify.preselect-optionsSHOPIFY_PRESELECT_OPTIONSfalsePreselect the first variant options on a product page.
shopify.auto-send-checkout-recoverySHOPIFY_AUTO_CHECKOUT_RECOVERYfalseHourly abandoned checkout mails (needs the scheduler).
shopify.checkout-recovery-delay-hoursSHOPIFY_CHECKOUT_RECOVERY_DELAY10Hours before a recovery mail may be sent.

The scheduled import dispatches ImportProducts and ImportCollections as jobs. With a queue other than sync they need the queue worker, see Deployment.

Product status per store 2.12 ​

The import reads the Storefront API, which only returns products published to the store's channel. Before 2.12 every product missing from the feed was treated the same and archived, so a product put back to draft for an afternoon looked exactly like a deleted one, and mirrors recorded nothing at all: their stale rows kept serving old prices and variants.

Since 2.12 every product has a status per store, stored on shopify_products_data.status with status_changed_at set whenever it changes:

StatusMeaningHow it is determined
publishedin the store's Storefront feedpresent in the feed
unpublishedexists and is active, but not on this store's channelAdmin API says ACTIVE, or no Admin answer is available
draftset to draft in ShopifyAdmin API says DRAFT
archivedarchived in Shopify (restorable there)Admin API says ARCHIVED
deleteddeleted in Shopifythe Admin API returns no product for the id

After each import of a store (master and every mirror), the products missing from the feed are classified with one Admin API query over just those ids. The sync degrades instead of guessing:

  • No Admin token, a 401 or any other Admin API error: a warning is logged and the product is recorded as unpublished.
  • A product is marked deleted only on an explicit Admin API answer that it no longer exists. An incomplete answer aborts the classification instead of being read as deletions.

What the status changes on the site:

  • Master store: a product that is not published there is hidden from the site (is_archived, as before). Guests get a 404 on its URL, it leaves the sitemap, and the import forgets its cached page so the page cache does not keep serving it.
  • Mirror store: a product that is not published on the mirror is no longer offered there (its store data, availableInCurrentStore(), the product page, which answers 404, and the mirror's Google Shopping feed), while it stays live on the master.
  • Product manager: a badge shows the reason when a product is not published on the selected store.
  • Webhooks and the manual "Sync" action: when the Storefront no longer returns the product, it is marked with the status from the webhook payload, or from the Admin API when there is none. Before 2.12 this path failed with a TypeError.

Existing rows have no status until the next import classifies them and count as published until then. Republishing a product in Shopify restores published with the next import.

Purging deleted products 2.12 ​

Nothing ever deleted a product before 2.12. Their store data, translations and routes stayed in the database; on one install about 70% of shopify_products_data belonged to products that no longer existed.

bash
php artisan shopify:purge-deleted-products --dry-run      # list candidates
php artisan shopify:purge-deleted-products                # purge, 30 day window
php artisan shopify:purge-deleted-products --days=7       # custom window
OptionDefaultEffect
--days=SHOPIFY_PURGE_DELETED_AFTER_DAYS, else 30Minimum days a product must have stayed deleted.
--dry-runLists what would be purged, deletes nothing.

A product is purged only when all of these hold:

  1. its status on the master store is deleted, which is only ever set on a confirmed Admin API answer,
  2. it is hidden from the site,
  3. it has been deleted for at least the retention window.

Draft, archived, unpublished and "missing from the feed" products are never touched. Each product is deleted through Eloquent, so its routes, blocks, media links, store data, translations and collection links go with it. Every purged product is logged (SHOPIFY: Purging product deleted in Shopify ...).

The purge only finds candidates once the import has classified products with a working Admin token. On a store without one, nothing is ever deleted, so nothing is purged.

Scheduling ​

Scheduling is opt-in:

ini
SHOPIFY_PURGE_DELETED_AFTER_DAYS=30

With the variable set, the scheduler runs the purge daily with that window. Unset, the command only runs by hand. The scheduler cron must be active (Deployment).

Deletion webhooks ​

The products/delete webhook is store aware since 2.12. A mirror deleting its copy removes only that store's data row; before, it looked the id up among the master's ids and did nothing. A deletion on the master removes the product as before. Both paths are logged.

Upgrading to 2.12 ​

Two migrations run with php artisan migrate (a deploy runs them automatically):

  1. add_status_to_shopify_products_data_table adds the nullable status and status_changed_at columns.
  2. add_cascading_foreign_keys_to_shopify_product_tables adds foreign keys with ON DELETE CASCADE on shopify_products_data.product_id, shopify_products_i18n.product_id, shopify_product_has_collections.product_id and .collection_id, and shopify_collections_i18n.collection_id.

The foreign key migration deletes data

Before adding each constraint, the migration deletes rows whose product or collection no longer exists. Those rows are unreachable by the application, but they are gone afterwards. Back up the database first on every install with real Shopify data.

A backup on the server, before deploying:

bash
php artisan evolve:ssh live
mysqldump --single-transaction -u <DB_USERNAME> -p <DB_DATABASE> | gzip > ~/backup-before-2.12.sql.gz

After the deploy, the log has one line per cleaned table:

bash
grep "SHOPIFY: Deleted" current/storage/logs/laravel*.log
# SHOPIFY: Deleted <n> orphaned rows from shopify_products_data (product_id without shopify_products)

On a legacy install where a constraint cannot be added (typically a column type that differs from the parent key), the migration logs a warning (SHOPIFY: Could not add a cascading foreign key on ...) instead of failing the deploy. The model hooks still clean up on ordinary deletes; the warning marks the gap.

Then, per store: check that the Admin token has read_products, run an import, and look at the classification in the log (SHOPIFY: Product statuses on <store>). Only after that is shopify:purge-deleted-products --dry-run meaningful.

ShopifyAdminClient gained getProductStatuses(array $adminIds): array. A project with its own implementation of that contract must add it.

Pitfalls ​

  • No Admin token, no statuses. Everything missing from the feed is unpublished, and nothing is purged. The shop keeps working.
  • Archived is not deleted. A product archived in Shopify is hidden and keeps its routes, custom path and SEO metadata so it can come back. Only confirmed deletions are purged.
  • The first purge after enabling it purges a backlog. Products deleted long ago are purged together on the first run. Look at --dry-run first.
  • Orphans without a backup cannot be restored. See the migration warning above.

Verifying on a server ​

bash
# Status distribution per store
php84 artisan tinker --execute='dump(DB::table("shopify_products_data")->selectRaw("store_id, status, count(*) as n")->groupBy("store_id", "status")->get())'

# What the purge would delete
php84 artisan shopify:purge-deleted-products --dry-run

# Is the daily purge scheduled?
php84 artisan schedule:list | grep purge-deleted-products