Files
core/docs/shopify-import.md

12 KiB
Raw Permalink Blame History

Shopify → Lunar product import

Findings from comparing a real Shopify product export CSV against Lunar's schema (vendor/lunarphp/core), plus the resulting implementation plan for MigrateImport\Shopify\ShopifyExportImporter.

Idempotency problem

Nothing in Lunar tracks "this record came from external system X, ID Y." Re-running an import with no external-ID tracking would duplicate every product on each run.

Solution: a dedicated import_mappings table, not columns on Lunar's core tables.

Schema::create($this->prefix.'import_mappings', function (Blueprint $table) {
    $table->id();
    $table->string('source');           // 'shopify'
    $table->string('source_type');      // 'product', 'variant', 'image', 'collection', 'tag'
    $table->string('external_id');      // Shopify handle, or handle+position for variants/images
    $table->morphs('model');            // model_type + model_id -> Product, ProductVariant, Asset, Collection, Tag
    $table->timestamps();

    $table->unique(['source', 'source_type', 'external_id']);
});

Every resolver looks up import_mappings by (source, source_type, external_id) first — update if found, create + insert mapping row if not.

CSV shape

Shopify's export is flat: one row per (product × variant × extra image), with Handle repeated across all rows belonging to the same product. Only the first row per handle carries Title/Body (HTML)/Vendor/etc — later rows are variant-only or pure image rows (no Option1 Value at all).

The importer must first group consecutive CSV rows by Handle into { handle, productFields, variants: [...], images: [...] } before touching Lunar.

Field mapping: CSV → Lunar

CSV column Lunar equivalent Notes
Title, Body (HTML) products.attribute_data (JSON, translatable) goes into attribute_data, not a flat column
Vendor products.brand (plain string column) direct passthrough
Product Category Collection (nested set) inside a CollectionGroup Google-taxonomy breadcrumb (e.g. Toys & Games > Toys > Bath Toys) — parse into a nested Collection chain, product attached to the deepest node. See "Collections" below.
Type product_type_id (required FK) must resolve/create a ProductType; needs a default fallback (e.g. "General")
Tags Tag model (separate, not built into Product) split CSV string, find/create, attach
Published / Status products.status (plain string, indexed) direct mapping
Option1/2/3 Name+Value product_options + product_option_values + variant pivot find/create option (by name), find/create value (by option+value), attach to variant
Variant SKU product_variants.sku direct
Variant Grams product_variants dimensions ($table->dimensions()) needs unit conversion check — macro likely adds weight_value/weight_unit, not raw grams
Variant Inventory Qty product_variants.stock direct
Variant Price / Compare At Price prices table (polymorphic priceable, per-currency, per-customer-group) not a variant column — create a Price row per variant per currency
Cost per item (nothing) Lunar has no cost-price field — stash in attribute_data as a custom attribute, in scope for v1
Variant Barcode closest is gtin/ean on variant ambiguous, needs a mapping rule
Variant Requires Shipping product_variants.shippable (boolean) direct
Variant Taxable tax_class_id (required FK, not boolean) must resolve to an actual TaxClass row (e.g. "Standard" vs "None")
Image Src (+ image-only rows) Asset model + Spatie MediaLibrary, ordered via media_product_variant pivot (position) see "Images" below — file is local, not a URL to download
SEO Title / SEO Description (nothing structured) stash in attribute_data as custom attributes, in scope for v1
Metafield columns (see below) (nothing) all empty across every row in the sample export — skip entirely
Handle Url model (urls table, polymorphic, per-language slug) create a Url row per product (slug = handle), not a plain column

Collections — Lunar supports multiple collections per product

Lunar's collection_product table is a plain many-to-many pivot (collection_id, product_id, position) with no uniqueness constraint tying a product to a single collection. A Product can belong to any number of Collections simultaneously, and Collections themselves are a nested-set tree (NodeTrait, _lft/_rgt/parent_id) scoped inside a CollectionGroup (collection_group_id).

This matters for the importer in two ways:

  • The breadcrumb import doesn't have to be exclusive. Attaching a product to Toys & Games > Toys > Bath Toys > Rubber Duckies doesn't prevent it from also being attached to a separately-curated collection (e.g. a manual "Best Sellers" collection) — no conflict, since it's a plain pivot.
  • Resolve the breadcrumb into whichever CollectionGroup fits the target site's structure — the known Shopify category tree can be parsed straight into it; no need for a separate import-only group unless you specifically want to isolate/purge imported collections independently later.
  • Only the deepest node of the breadcrumb needs the product attached directly — Lunar's nested-set ancestry (getBreadcrumbAttribute()) already exposes the full parent chain via $collection->ancestors, so there's no need to attach the product to every intermediate level.

Resolver logic: split Product Category on >, trim each segment, walk down creating/reusing Collection nodes as a chain (parent_id chained) within the fixed import CollectionGroup, then attach the product only to the final (leaf) Collection via collection_product.

Images — the export includes a local files folder

The Shopify export isn't just the CSV — it also produces a folder of the actual image files, named by UUID (not by the original filename or Image Src URL). The Image Src column in the CSV is the original Shopify CDN URL, which is not how the file is named on disk in the export folder.

This changes the AssetResolver from an HTTP-download step to a local-file-lookup step:

  • The importer needs a way to map each CSV Image Src row to its corresponding UUID-named file in the export folder — check whether the export also provides a manifest/mapping file, or whether the mapping has to be inferred from row order per Handle (i.e. Nth image row for a handle = Nth file in that handle's image set, if the export preserves ordering).
  • Once resolved, the local file is what gets added to the Asset + Spatie MediaLibrary (addMedia($localPath)), not a downloaded copy of the CDN URL — no HTTP client / retry / timeout handling needed for images, but a file-existence check is needed (missing/renamed file in the export folder should not silently fail the whole product import).
  • import_mappings for images should still key on something stable from the CSV (e.g. Image Src URL, or handle + position) rather than the UUID filename, since the UUID is regenerated on every fresh export and isn't a stable external ID across re-imports.

Metafield columns — confirmed empty, skipped

These are Shopify's auto-generated category-specific metafields (populated once a product has a Product Category assigned). In the sample export they were empty for effectively every row (a few had color-pattern, material, or target-gender populated). Decision: skip all of these for v1, since none carry data worth preserving in this dataset.

Product rating count (product.metafields.reviews.rating_count)
Age group (product.metafields.shopify.age-group)
Barware material (product.metafields.shopify.barware-material)
Board game mechanics (product.metafields.shopify.board-game-mechanics)
Color (product.metafields.shopify.color-pattern)
Construction set theme (product.metafields.shopify.construction-set-theme)
Decoration material (product.metafields.shopify.decoration-material)
Dexterity skills (product.metafields.shopify.dexterity-skills)
Dice shape (product.metafields.shopify.dice-shape)
Difficulty level (product.metafields.shopify.difficulty-level)
Doll/Playset features (product.metafields.shopify.doll-playset-features)
Finish (product.metafields.shopify.finish)
Gameplay skills (product.metafields.shopify.gameplay-skills)
Hardware material (product.metafields.shopify.hardware-material)
Material (product.metafields.shopify.material)
Pet trainer type (product.metafields.shopify.pet-trainer-type)
Plant class (product.metafields.shopify.plant-class)
Plant name (product.metafields.shopify.plant-name)
Puzzle theme (product.metafields.shopify.puzzle-theme)
Puzzle type (product.metafields.shopify.puzzle-type)
Recommended age group (product.metafields.shopify.recommended-age-group)
Shape (product.metafields.shopify.shape)
Stationery binding type (product.metafields.shopify.stationery-binding-type)
Suitable space (product.metafields.shopify.suitable-space)
Target gender (product.metafields.shopify.target-gender)
Theme (product.metafields.shopify.theme)
Toy/Game material (product.metafields.shopify.toy-game-material)
Usage type (product.metafields.shopify.usage-type)
Vase shape (product.metafields.shopify.vase-shape)

Resolvers, in dependency order

Each resolver is idempotent: find-by-natural-key-or-create, checking import_mappings where applicable.

  1. TaxClassResolver — maps Variant Taxable (bool) to a fixed TaxClass ("Standard" vs "None"), not a per-row lookup.
  2. ProductTypeResolver — maps CSV Type to ProductType; falls back to a default (e.g. "General") since product_type_id is required.
  3. BrandResolverVendorproducts.brand; likely a passthrough, not a real resolver, since it's a plain string column.
  4. TagResolver — splits Tags, finds/creates Tag rows, attaches to product.
  5. CollectionResolver — parses Product Category breadcrumb into a nested Collection chain within the target CollectionGroup (find/create per level), attaches product only to the deepest (leaf) Collection. Does not touch or conflict with any other collections the product may already belong to — a product can be in multiple collections at once.
  6. ProductOptionResolver — for each Option1/2/3 Name, find/create ProductOption + ProductOptionValue, reused across products.
  7. AssetResolver — resolves each CSV image row to its local UUID-named file in the export's images folder (see "Images" above), creates Asset + Spatie media from that local file, attaches to product and/or specific variants with position ordering.
  8. PriceResolver — creates Price row(s) per variant per currency from Variant Price / Variant Compare At Price.

Orchestration order per product group

1. Resolve/create ProductType, TaxClass, Brand
2. Create or update Product (import_mappings: source_type='product', external_id=handle)
   - write Title/Body, Cost per item, SEO title/description into attribute_data
3. Create/attach Url (slug = handle)
4. Resolve/attach Tags
5. Resolve/attach Collection chain (parsed from Product Category breadcrumb, within the target CollectionGroup; attach product to leaf Collection only)
6. For each Option1/2/3 Name -> resolve ProductOption
7. For each variant row:
     - resolve ProductOptionValues for that variant's option combination
     - create or update ProductVariant (import_mappings: source_type='variant', external_id=handle+variant-index)
     - attach ProductOptionValues to variant
     - create/update Price row(s)
8. For each image row:
     - resolve the CSV row to its local UUID-named file in the export's images folder; skip with a logged warning if the file is missing
     - resolve/create Asset from the local file (import_mappings: source_type='image', external_id=image URL or handle+position — not the UUID filename, which is regenerated per export)
     - attach to Product, and to specific ProductVariant if the image is variant-scoped

Explicitly out of scope for v1

  • Metafield columns (confirmed empty — see above).