delete() — * Product/ProductVariant use Spatie's InteractsWithMedia (see Lunar\Base\ * Traits\HasMedia), which only cleans up media files/rows on a real model * `deleted` event, never on a raw query-builder delete. */ class WipeCatalogCommand extends Command { protected $signature = 'boboko:wipe-catalog {--email= : Staff email to send the confirmation code to}'; protected $description = 'Irreversibly delete every product, variant, and related catalog data'; public function handle(OtpService $otp): int { // withTrashed() — a prior soft-delete-only bug in this command // (fixed in wipe() below) could leave ghost rows a plain count() // would never see, silently reporting "nothing to do" while they // sit there breaking other things (e.g. the admin's own global // search, which assumes every returned product has variants). $productCount = Product::withTrashed()->count(); if ($productCount === 0) { $this->info('No products exist — nothing to do.'); return self::SUCCESS; } if (! $this->authorize($otp)) { return self::FAILURE; } $this->warn("This will PERMANENTLY delete {$productCount} product(s) and everything that only exists because of them (variants, prices, images, product-option assignments, associations). This cannot be undone."); $typed = text(label: "Type the product count ({$productCount}) to confirm"); if ($typed !== (string) $productCount) { $this->error('Count did not match — aborted, nothing was deleted.'); return self::FAILURE; } $this->wipe(); $this->info("Deleted {$productCount} product(s) and all related data."); return self::SUCCESS; } private function authorize(OtpService $otp): bool { $email = $this->option('email') ?? text( label: 'Staff email to send a confirmation code to', validate: fn (string $value) => Staff::where('email', $value)->exists() ? null : 'No staff account with that email exists.', ); if (! $otp->generateAndSend($email, purpose: 'wipe-catalog')) { $this->error('Could not send a confirmation code to that email.'); return false; } $this->info("A confirmation code was sent to {$email}."); $code = password(label: 'Enter the confirmation code'); if ($otp->validate($email, $code) === null) { $this->error('Invalid or expired code — aborted, nothing was deleted.'); return false; } return true; } /** * Every step below goes through a real Eloquent relation, never a raw * table name — Lunar's own table prefix is configurable * (config('lunar.database.table_prefix'), applied in BaseModel's * constructor), so a hardcoded 'lunar_...' string would silently * no-op on an install using a different one. * * Order matters: product_associations and the product/product_option * pivot have a real FK to `products` but no ON DELETE CASCADE (both * RESTRICT, Laravel's own default), so they're detached before the * product/variant rows they reference — deleting a product that * still has either would throw. ProductVariant's own `prices` (a * plain morph, HasPrices trait — no FK constraint at all) would * otherwise silently orphan rather than throw, so it's cleared the * same way regardless. media_variant and product_option_value_ * product_variant DO cascade at the DB level (see their own * migrations), so deleting the variant itself is enough for those two. * * Deliberately NOT chunkById() — that re-queries "id > lastSeenId" * every iteration, but deleting rows inside the loop shrinks the * table out from under it: any product whose id fell in a range * chunkById() had already stepped past could be silently skipped and * never actually deleted at all. Caught in practice — the first real * run of this command left orphaned Media rows (Spatie's own * deleteAllMedia(), fired from Product's `deleting` event, never ran * for the skipped products) whose 'image' ImportMapping rows then * caused a LATER Shopify re-import to silently reuse those now- * orphaned Media objects instead of importing fresh ones — see * MigrateImport\Shopify\Services\ShopifyExportImporter::resolveOrImportImage()'s * own docblock for that half of the same incident. Always re-querying * the first N remaining rows (never advancing an id cursor) guarantees * every product is actually visited exactly once, however many are * deleted out from under the query as it goes. */ private function wipe(): void { ImportMapping::whereIn('source_type', ['product', 'variant', 'image'])->delete(); // Only the line items — not the parent Cart rows. This command is // meant for early-stage/setup use where no real customer carts // matter yet, but a customer's Cart record also anchors their // session/coupon/address state; deleting it outright is more than // "the catalog is gone" calls for. Leaving every variant a cart // line could reference about to be force-deleted below would // otherwise reproduce the exact storefront crash this step exists // to prevent: CartLine::purchasable() resolves to null, // PricingManager::for() throws a TypeError on every page load that // renders the cart drawer. CartLine::where('purchasable_type', 'product_variant')->delete(); while (true) { // withTrashed(): Product/ProductVariant both use SoftDeletes // — a plain query would stop seeing a product the moment // forceDelete() below actually removes it, which is fine, but // WITHOUT withTrashed() here this loop would never even // fetch a row that a previous, buggy run of this command // (or any other code) had already soft-deleted without // force-deleting it. Ghost rows like that are exactly what // this command exists to remove. $products = Product::withTrashed() ->with(['variants' => fn ($query) => $query->withTrashed(), 'associations', 'inverseAssociations']) ->limit(100) ->get(); if ($products->isEmpty()) { break; } foreach ($products as $product) { $product->associations()->delete(); $product->inverseAssociations()->delete(); $product->productOptions()->detach(); foreach ($product->variants as $variant) { $variant->prices()->delete(); // NOT delete() — Product/ProductVariant both use // SoftDeletes, and a plain delete() only sets // deleted_at, leaving the row (and, for Product, its // media) sitting in the table. This command's whole // purpose is an irreversible wipe; a soft-deleted // ghost row is the opposite of that. Caught in // practice — a prior run's plain delete() left 185 // ghost Product rows with zero real variants, which // then crashed the admin's own global search // (Lunar\Admin\Filament\Resources\ProductResource:: // getGlobalSearchResultDetails() assumes // $record->variants->first() is never null). $variant->forceDelete(); } $product->forceDelete(); } } Product::removeAllFromSearch(); } }