<?php
namespace App\Console\Commands;
use App\Modules\Catalog\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* One-time backfill: migrates legacy product_images rows (created by
* products:download-images — plain files under storage/app/public, only
* ever referenced via the product_images table, no Spatie Media row) into
* real Spatie MediaLibrary entries on the featured_image/gallery
* collections.
*
* Why this is needed: Filament's SpatieMediaLibraryFileUpload component
* (used for native, in-dropzone image previews on the edit-product form)
* only ever reads from Spatie Media. Products created/edited through the
* app already get real Spatie Media rows automatically — but the ~1400
* products bulk-imported earlier only have product_images rows pointing
* at plain files, so without this backfill they'd show an empty dropzone
* on edit even though the images clearly exist.
*
* This command is additive and non-destructive:
* - It does NOT delete or move the original files (addMediaFromDisk()
* is called with preservingOriginal(), so Spatie copies the file into
* its own managed media directory rather than adopting the original).
* - It does NOT touch product_images rows except to fill in media_id
* once a matching Spatie Media record exists, per the column's
* original documented purpose (see the create_product_images_table
* migration).
* - Products that already have Spatie media in featured_image/gallery
* are skipped by default (assumed already migrated / created via the
* app directly) — pass --force to re-scan them anyway.
*
* Usage:
* php artisan products:backfill-media --dry-run # report only, writes nothing
* php artisan products:backfill-media # apply for real
* php artisan products:backfill-media --force # also re-scan products that already have Spatie media
*/
class BackfillProductMediaCommand extends Command
{
protected $signature = 'products:backfill-media
{--dry-run : Report what would happen without writing any data}
{--force : Also re-scan products that already have Spatie media in featured_image/gallery}';
protected $description = 'Backfill legacy product_images records into Spatie Media Library so all products get native FilePond previews';
public function handle(): int
{
// This command processes ~1400 products (with image conversions —
// thumb/medium/large — generated synchronously per image via GD)
// in one long-running process. Two things Laravel/PHP normally rely
// on short-lived requests for can silently pile up over that many
// iterations and blow past the default 128M memory_limit:
// 1. Eloquent's query log, which is NOT capped by default and
// records every query for the life of the process.
// 2. Cyclic references (e.g. Eloquent models holding their own
// loaded relations) that only PHP's cycle-collecting GC frees,
// not simple refcounting — and it doesn't run on every free().
// Fixing both here means this command is memory-safe regardless of
// how it's invoked (no need to remember a -d memory_limit= flag).
DB::connection()->disableQueryLog();
ini_set('memory_limit', '1024M');
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$total = Product::query()->has('images')->count();
$this->info(($dryRun ? '[DRY RUN] ' : '') . "Scanning {$total} product(s) with legacy images...");
$bar = $this->output->createProgressBar($total);
$bar->start();
$stats = [
'products scanned' => 0,
'products skipped (already has media)' => 0,
'products migrated' => 0,
'thumbnails migrated' => 0,
'gallery images migrated' => 0,
'rows skipped (already had media_id)' => 0,
'files missing on disk (skipped)' => 0,
];
Product::query()
->has('images')
->with(['images' => fn ($q) => $q->orderByDesc('is_primary')->orderBy('sort_order')])
->chunkById(50, function ($products) use (&$stats, $dryRun, $force, $bar) {
foreach ($products as $product) {
$stats['products scanned']++;
$bar->advance();
$hasSpatieMedia = $product->getMedia('featured_image')->isNotEmpty()
|| $product->getMedia('gallery')->isNotEmpty();
if ($hasSpatieMedia && ! $force) {
$stats['products skipped (already has media)']++;
continue;
}
$migratedAny = false;
foreach ($product->images as $image) {
if ($image->media_id) {
$stats['rows skipped (already had media_id)']++;
continue;
}
$disk = $image->disk ?: 'public';
$relativePath = $this->relativePathFromUrl($image->url);
if (! $relativePath || ! Storage::disk($disk)->exists($relativePath)) {
$stats['files missing on disk (skipped)']++;
continue;
}
$collection = $image->is_primary ? 'featured_image' : 'gallery';
if ($dryRun) {
$migratedAny = true;
$image->is_primary ? $stats['thumbnails migrated']++ : $stats['gallery images migrated']++;
continue;
}
$media = $product
->addMediaFromDisk($relativePath, $disk)
->preservingOriginal()
->usingName($image->alt_text ?: $product->getTranslation('name', 'en'))
->toMediaCollection($collection);
$image->update(['media_id' => $media->id]);
$migratedAny = true;
$image->is_primary ? $stats['thumbnails migrated']++ : $stats['gallery images migrated']++;
}
if ($migratedAny) {
$stats['products migrated']++;
}
}
// Release this chunk's models/relations (and anything Spatie's
// FileAdder/Intervention Image left behind processing them)
// before the next chunk loads — see the memory note in handle().
unset($products);
gc_collect_cycles();
});
$bar->finish();
$this->newLine(2);
foreach ($stats as $label => $value) {
$this->line(ucfirst($label) . ': ' . $value);
}
if ($dryRun) {
$this->comment('Dry run only — no data was written. Re-run without --dry-run to apply.');
} else {
$this->info('Done.');
}
return self::SUCCESS;
}
/**
* Convert a stored public URL (e.g. "http://host/storage/products/seeded/x.jpg"
* or "/storage/products/seeded/x.jpg") back into a disk-relative path
* (e.g. "products/seeded/x.jpg") that Storage::disk('public') understands.
*/
protected function relativePathFromUrl(?string $url): ?string
{
if (! $url) {
return null;
}
$path = parse_url($url, PHP_URL_PATH) ?: $url;
$path = preg_replace('#^/?storage/#', '', $path);
return ltrim($path, '/');
}
}