# 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. ```php 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 `Collection`s simultaneously, and `Collection`s 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. **BrandResolver** — `Vendor` → `products.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).