6.0 KiB
Wiping products before a clean Shopify re-import
A runbook for discarding every imported product (and everything that hangs off one —
variants, prices, media, reviews, options/values, the Meilisearch documents) and re-running
ShopifyExportImporter from scratch. Useful after a schema/indexer change that only applies to
newly-created rows (see "Why a wipe, not an update" below), or when the export CSV itself changed
enough that stale products need to go, not just be updated in place.
Every command below is a tinker --execute= one-liner run inside the app container — adjust the
exec prefix (./bin/dc-core.sh exec app ..., docker compose exec app ..., etc.) for your setup.
Why a wipe, not an update
ShopifyExportImporter's resolvers are mostly firstOrCreate — re-running the importer against
an existing database updates matched rows but leaves already-created ones exactly as they were.
That's the right behavior for routine re-imports (an updated price, a new variant), but it means a
change to what gets set at creation time only — e.g. ProductOptionResolver now also setting
label, not just name, on a ProductOption — never reaches a ProductOption row that already
exists. A wipe forces every row to go through creation again, picking up such fixes.
1. Delete every product
Cascades to ProductVariant, prices, and Spatie media rows — verified live (see
shopify-import.md's own history/commit log for context). Also removes each product's Meilisearch
document automatically, via Scout's own delete hook fired on forceDelete() — no separate
scout:flush needed.
\Lunar\Models\Product::withTrashed()->get()->each->forceDelete();
Let this run to completion. Interrupting it mid-loop (e.g. Ctrl+C on the tinker session) stops after whichever product it was on, leaving the rest undeleted — safe to just re-run the same command again afterward, since already-deleted products are simply skipped.
Verify:
\Lunar\Models\Product::withTrashed()->count(); // 0
Requires: product_reviews.product_id cascades on delete
product_reviews (boboko-core's own table, not Lunar's) originally had no ON DELETE clause on
its product_id foreign key — deleting a reviewed product threw a constraint violation instead of
the review going with it. Fixed by
database/migrations/2026_09_03_000001_add_cascade_delete_to_product_reviews_product_id.php. Make
sure this migration has actually run (php artisan migrate) before step 1, or a product with
reviews will fail to delete.
2. Delete product options and values
Not touched by step 1 (ProductOption/ProductOptionValue aren't scoped to one product — they're
shared across the catalog, per ProductOptionResolver::resolveOption()'s shared: true). Safe to
delete in full once every product (and therefore every variant referencing an option value via the
product_option_value_product_variant pivot) is gone — deleting values while variants still
reference them throws the same kind of FK violation step 1 guards against.
\Lunar\Models\ProductOptionValue::query()->delete();
\Lunar\Models\ProductOption::query()->delete();
Verify:
\Lunar\Models\ProductOption::count(); // 0
\Lunar\Models\ProductOptionValue::count(); // 0
3. Clear the import mappings
Without this, the importer's ImportMapping::resolve(...) calls still find the (now-deleted)
mappings' rows absent, so this step is really about not leaving stale mapping rows pointing at
nothing — ImportMapping rows aren't foreign-keyed to the models they map (morphTo, no
constraint), so leaving them wouldn't break the re-import, but a stale mapping for a product that
no longer exists is dead weight.
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->delete();
Verify:
\Modules\Core\MigrateImport\Models\ImportMapping::where('source', 'shopify')->count(); // 0
4. Re-run the importer
boboko:migrate:import dispatches RunMigrateImportJob onto the queue — not synchronous —
so a queue worker must actually be running (php artisan queue:work, or your dev queue container)
or the job just sits queued.
php artisan boboko:migrate:import --source=shopify --type=export --file=<absolute path to the CSV>
The --file value must be an absolute path inside the container (e.g.
/var/www/html/storage/app/private/imports/shopify/products_export.csv) when running
non-interactively — a path relative to storage/app/private/imports only resolves correctly when
the command can fall back to its interactive prompt, which isn't available in a scripted/non-TTY
run.
Watch the queue worker's own log output for FAIL entries (see docs/lunar.md or your compose
setup for how logs are routed to docker compose logs) — a clean run shows every
Laravel\Scout\Jobs\MakeSearchable / Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob
line ending DONE, never FAIL.
5. Re-sync Meilisearch and reindex
php artisan lunar:meilisearch:setup
php artisan lunar:meilisearch:tune-product-search
php artisan lunar:search:index "Lunar\Models\Product" --refresh
--refresh re-syncs filterable/sortable index settings and reindexes every document — it does
not reset typoTolerance/prefixSearch (confirmed live: both survived a --refresh run
unchanged), so tune-product-search only needs re-running here for completeness/if it hadn't
already been applied, not because --refresh would have clobbered it.
Verifying the result
// Product count should match the CSV's actual unique `Handle` count, not
// whatever the database held before the wipe — those aren't the same number
// if stale/manually-added products existed alongside the CSV-sourced ones.
\Lunar\Models\Product::count();
// Spot-check that at least one variant picked up its own image (see
// shopify-import.md's "Images" section) — 0 is only correct if the CSV
// genuinely has no `Variant Image` values populated.
\Lunar\Models\ProductVariant::has('images')->count();