12 KiB
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 Duckiesdoesn'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
CollectionGroupfits 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 Srcrow 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 perHandle(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_mappingsfor images should still key on something stable from the CSV (e.g.Image SrcURL, orhandle + 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.
- TaxClassResolver — maps
Variant Taxable(bool) to a fixedTaxClass("Standard" vs "None"), not a per-row lookup. - ProductTypeResolver — maps CSV
TypetoProductType; falls back to a default (e.g. "General") sinceproduct_type_idis required. - BrandResolver —
Vendor→products.brand; likely a passthrough, not a real resolver, since it's a plain string column. - TagResolver — splits
Tags, finds/createsTagrows, attaches to product. - CollectionResolver — parses
Product Categorybreadcrumb into a nestedCollectionchain within the targetCollectionGroup(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. - ProductOptionResolver — for each
Option1/2/3 Name, find/createProductOption+ProductOptionValue, reused across products. - 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 withpositionordering. - PriceResolver — creates
Pricerow(s) per variant per currency fromVariant 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).