command: sed -n '1,660p' app/Livewire/Admin/Products/Form.php

aggregated_output:
<?php

namespace App\Livewire\Admin\Products;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Enums\ProductStatus;
use App\Jobs\ProcessMediaUpload;
use App\Models\Collection;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use App\Support\Money;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;
use Livewire\WithFileUploads;

class Form extends Component
{
    use WithFileUploads;

    public ?Product $product = null;

    public string $title = '';

    public string $descriptionHtml = '';

    public string $status = 'active';

    public string $vendor = '';

    public string $productType = '';

    public string $tags = '';

    public string $handle = '';

    public ?string $publishedAt = null;

    /**
     * @var array<int, int>
     */
    public array $collectionIds = [];

    /**
     * @var array<int, array{name: string, values: string}>
     */
    public array $options = [];

    /**
     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool}>
     */
    public array $variants = [];

    /**
     * @var array<int, array{id: int, url: string, exists: bool, altText: string, position: int, status: string}>
     */
    public array $media = [];

    /**
     * @var array<int, mixed>
     */
    public array $newMedia = [];

    public function mount(?Product $product = null): void
    {
        if ($product?->exists) {
            $store = app('current_store');

            abort_unless($store instanceof Store && (int) $product->store_id === $store->getKey(), 404);

            $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
            $this->fillFromProduct($this->product);

            return;
        }

        $this->variants = [[
            'id' => null,
            'label' => 'Default',
            'sku' => '',
            'price' => '0.00',
            'compareAtPrice' => '',
            'quantity' => 0,
            'requiresShipping' => true,
        ]];
    }

    public function updatedTitle(): void
    {
        if ($this->product === null && $this->handle === '') {
            $this->handle = Str::slug($this->title);
        }
    }

    public function addOption(): void
    {
        $this->options[] = [
            'name' => '',
            'values' => '',
        ];
    }

    public function removeOption(int $index): void
    {
        unset($this->options[$index]);
        $this->options = array_values($this->options);
    }

    public function save(): void
    {
        $store = app('current_store');
        abort_unless($store instanceof Store, 404);

        $this->validate([
            'title' => ['required', 'string', 'max:255'],
            'descriptionHtml' => ['nullable', 'string', 'max:65535'],
            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
            'vendor' => ['nullable', 'string', 'max:255'],
            'productType' => ['nullable', 'string', 'max:255'],
            'tags' => ['nullable', 'string'],
            'handle' => [
                'required',
                'string',
                'max:255',
                Rule::unique('products', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($this->product?->getKey()),
            ],
            'variants' => ['required', 'array', 'min:1'],
            'variants.*.sku' => ['nullable', 'string', 'max:255'],
            'variants.*.price' => ['required', 'numeric', 'min:0'],
            'variants.*.compareAtPrice' => ['nullable', 'numeric', 'min:0'],
            'variants.*.quantity' => ['required', 'integer', 'min:0'],
            'variants.*.requiresShipping' => ['boolean'],
            'newMedia' => ['array', 'max:10'],
            'newMedia.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
        ]);

        if (! $this->validateActiveVariantPricing() || ! $this->validateVariantSkus($store)) {
            return;
        }

        $product = DB::transaction(function () use ($store): Product {
            $productService = app(ProductService::class);

            if ($this->product === null) {
                $product = $productService->create($store, [
                    ...$this->productPayload(),
                    'options' => $this->optionPayload(),
                    'variants' => $this->variantPayload($store),
                ]);
            } else {
                $payload = $this->productPayload();
                $requestedStatus = ProductStatus::from((string) $payload['status']);
                unset($payload['status']);

  …9873 chars truncated…=> $position]);

            $variant->forceFill([
                'sku' => $variantData['sku'],
                'price_amount' => $variantData['price_amount'],
                'compare_at_amount' => $variantData['compare_at_amount'],
                'currency' => $variantData['currency'],
                'requires_shipping' => $variantData['requires_shipping'],
                'is_default' => $position === 0,
                'position' => $position,
            ])->save();

            InventoryItem::withoutGlobalScopes()->updateOrCreate(
                ['variant_id' => $variant->getKey()],
                [
                    'store_id' => $store->getKey(),
                    'quantity_on_hand' => $variantData['quantity_on_hand'],
                    'quantity_reserved' => 0,
                    'policy' => 'deny',
                ],
            );
        }
    }

    private function validateActiveVariantPricing(): bool
    {
        if ($this->status !== ProductStatus::Active->value) {
            return true;
        }

        $hasPricedVariant = collect($this->variants)
            ->contains(fn (array $variant): bool => Money::fromDecimalString($variant['price']) > 0);

        if ($hasPricedVariant) {
            return true;
        }

        $this->addError('variants.0.price', __('At least one priced variant is required before activation.'));

        return false;
    }

    private function validateVariantSkus(Store $store): bool
    {
        $skus = collect($this->variants)
            ->pluck('sku')
            ->map(fn (?string $sku): string => trim((string) $sku))
            ->filter()
            ->values();

        if ($skus->isEmpty()) {
            return true;
        }

        if ($skus->duplicates()->isNotEmpty()) {
            $this->addError('variants.0.sku', __('Each variant SKU must be unique for this store.'));

            return false;
        }

        $variantIds = collect($this->variants)
            ->pluck('id')
            ->filter()
            ->map(fn (mixed $variantId): int => (int) $variantId)
            ->values()
            ->all();

        $conflictingSku = ProductVariant::withoutGlobalScopes()
            ->whereIn('sku', $skus->all())
            ->whereHas('product', function ($query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            })
            ->when($variantIds !== [], fn ($query) => $query->whereKeyNot($variantIds))
            ->value('sku');

        if ($conflictingSku === null) {
            return true;
        }

        $this->addError('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku]));

        return false;
    }

    private function storeNewMedia(Product $product): void
    {
        $maxPosition = ProductMedia::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->max('position');
        $position = $maxPosition === null ? 0 : ((int) $maxPosition) + 1;

        foreach ($this->newMedia as $file) {
            $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'jpg');
            $storageKey = $file->storeAs(
                "media/originals/{$product->getKey()}",
                Str::uuid().'.'.$extension,
                'public',
            );

            $media = ProductMedia::withoutGlobalScopes()->create([
                'product_id' => $product->getKey(),
                'type' => MediaType::Image,
                'storage_key' => $storageKey,
                'alt_text' => $product->title,
                'position' => $position++,
                'status' => MediaStatus::Processing,
            ]);

            ProcessMediaUpload::dispatch($media->getKey(), (int) $product->store_id);
        }

        $this->reset('newMedia');
    }

    private function productForAction(): Product
    {
        $store = app('current_store');

        abort_unless($store instanceof Store && $this->product instanceof Product, 404);

        return Product::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->findOrFail($this->product->getKey());
    }

    private function mediaRecord(int $mediaId): ProductMedia
    {
        return ProductMedia::withoutGlobalScopes()
            ->where('product_id', $this->productForAction()->getKey())
            ->findOrFail($mediaId);
    }

    private function mediaIndex(int $mediaId): int
    {
        $index = collect($this->media)->search(fn (array $media): bool => $media['id'] === $mediaId);

        abort_if($index === false, 404);

        return (int) $index;
    }

    private function refreshProductMedia(): void
    {
        $this->product = $this->productForAction()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
        $this->fillFromProduct($this->product);
    }
}


output:
Chunk ID: e1f825
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 4969
Output:
<?php

namespace App\Livewire\Admin\Products;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Enums\ProductStatus;
use App\Jobs\ProcessMediaUpload;
use App\Models\Collection;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductMedia;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use App\Support\Money;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;
use Livewire\WithFileUploads;

class Form extends Component
{
    use WithFileUploads;

    public ?Product $product = null;

    public string $title = '';

    public string $descriptionHtml = '';

    public string $status = 'active';

    public string $vendor = '';

    public string $productType = '';

    public string $tags = '';

    public string $handle = '';

    public ?string $publishedAt = null;

    /**
     * @var array<int, int>
     */
    public array $collectionIds = [];

    /**
     * @var array<int, array{name: string, values: string}>
     */
    public array $options = [];

    /**
     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool}>
     */
    public array $variants = [];

    /**
     * @var array<int, array{id: int, url: string, exists: bool, altText: string, position: int, status: string}>
     */
    public array $media = [];

    /**
     * @var array<int, mixed>
     */
    public array $newMedia = [];

    public function mount(?Product $product = null): void
    {
        if ($product?->exists) {
            $store = app('current_store');

            abort_unless($store instanceof Store && (int) $product->store_id === $store->getKey(), 404);

            $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
            $this->fillFromProduct($this->product);

            return;
        }

        $this->variants = [[
            'id' => null,
            'label' => 'Default',
            'sku' => '',
            'price' => '0.00',
            'compareAtPrice' => '',
            'quantity' => 0,
            'requiresShipping' => true,
        ]];
    }

    public function updatedTitle(): void
    {
        if ($this->product === null && $this->handle === '') {
            $this->handle = Str::slug($this->title);
        }
    }

    public function addOption(): void
    {
        $this->options[] = [
            'name' => '',
            'values' => '',
        ];
    }

    public function removeOption(int $index): void
    {
        unset($this->options[$index]);
        $this->options = array_values($this->options);
    }

    public function save(): void
    {
        $store = app('current_store');
        abort_unless($store instanceof Store, 404);

        $this->validate([
            'title' => ['required', 'string', 'max:255'],
            'descriptionHtml' => ['nullable', 'string', 'max:65535'],
            'status' => ['required', Rule::in(['draft', 'active', 'archived'])],
            'vendor' => ['nullable', 'string', 'max:255'],
            'productType' => ['nullable', 'string', 'max:255'],
            'tags' => ['nullable', 'string'],
            'handle' => [
                'required',
                'string',
                'max:255',
                Rule::unique('products', 'handle')
                    ->where('store_id', $store->getKey())
                    ->ignore($this->product?->getKey()),
            ],
            'variants' => ['required', 'array', 'min:1'],
            'variants.*.sku' => ['nullable', 'string', 'max:255'],
            'variants.*.price' => ['required', 'numeric', 'min:0'],
            'variants.*.compareAtPrice' => ['nullable', 'numeric', 'min:0'],
            'variants.*.quantity' => ['required', 'integer', 'min:0'],
            'variants.*.requiresShipping' => ['boolean'],
            'newMedia' => ['array', 'max:10'],
            'newMedia.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
        ]);

        if (! $this->validateActiveVariantPricing() || ! $this->validateVariantSkus($store)) {
            return;
        }

        $product = DB::transaction(function () use ($store): Product {
            $productService = app(ProductService::class);

            if ($this->product === null) {
                $product = $productService->create($store, [
                    ...$this->productPayload(),
                    'options' => $this->optionPayload(),
                    'variants' => $this->variantPayload($store),
                ]);
            } else {
                $payload = $this->productPayload();
                $requestedStatus = ProductStatus::from((string) $payload['status']);
                unset($payload['status']);

                $product = $productService->update($this->product, $payload);
                $this->syncExistingVariants($product, $store);

                if ($requestedStatus !== $product->refresh()->status) {
                    $productService->transitionStatus($product, $requestedStatus);
                }
            }

            $product->collections()->sync($this->collectionIds);

            return $product->refresh();
        });

        if ($this->newMedia !== []) {
            $this->storeNewMedia($product);
        }

        $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
        $this->fillFromProduct($this->product);

        session()->flash('status', 'Product saved');
        $this->dispatch('toast', type: 'success', message: __('Product saved'));
    }

    public function uploadMedia(): void
    {
        $product = $this->productForAction();

        $this->validate([
            'newMedia' => ['required', 'array', 'max:10'],
            'newMedia.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
        ]);

        $this->storeNewMedia($product);

        $this->product = $product->refresh()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
        $this->fillFromProduct($this->product);

        session()->flash('status', 'Media uploaded');
        $this->dispatch('toast', type: 'success', message: __('Media uploaded'));
    }

    public function updateMediaAlt(int $mediaId): void
    {
        $index = $this->mediaIndex($mediaId);

        $this->validate([
            "media.{$index}.altText" => ['nullable', 'string', 'max:255'],
        ]);

        $media = $this->mediaRecord($mediaId);
        $altText = trim((string) $this->media[$index]['altText']);

        $media->forceFill([
            'alt_text' => $altText === '' ? null : $altText,
        ])->save();

        $this->refreshProductMedia();

        session()->flash('status', 'Media updated');
    }

    public function moveMedia(int $mediaId, string $direction): void
    {
        $ids = collect($this->media)->pluck('id')->map(fn (int $id): int => $id)->all();
        $index = array_search($mediaId, $ids, true);

        if ($index === false) {
            return;
        }

        $swapIndex = match ($direction) {
            'up' => $index - 1,
            'down' => $index + 1,
            default => $index,
        };

        if (! isset($ids[$swapIndex])) {
            return;
        }

        [$ids[$index], $ids[$swapIndex]] = [$ids[$swapIndex], $ids[$index]];

        $this->reorderMedia($ids);
    }

    /**
     * @param  array<int, int>  $order
     */
    public function reorderMedia(array $order): void
    {
        $product = $this->productForAction();
        $validIds = ProductMedia::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->whereIn('id', $order)
            ->pluck('id')
            ->all();

        $orderedIds = collect($order)
            ->map(fn (mixed $mediaId): int => (int) $mediaId)
            ->intersect($validIds)
            ->values();

        DB::transaction(function () use ($orderedIds): void {
            foreach ($orderedIds as $position => $mediaId) {
                ProductMedia::withoutGlobalScopes()
                    ->whereKey($mediaId)
                    ->update(['position' => $position]);
            }
        });

        $this->refreshProductMedia();
    }

    public function removeMedia(int $mediaId): void
    {
        $media = $this->mediaRecord($mediaId);

        $media->delete();

        $this->reorderMedia(
            collect($this->media)
                ->pluck('id')
                ->reject(fn (int $id): bool => $id === $mediaId)
                ->values()
                ->all(),
        );

        session()->flash('status', 'Media removed');
    }

    public function deleteProduct(): void
    {
        abort_unless($this->product instanceof Product, 404);

        app(ProductService::class)->transitionStatus($this->product, ProductStatus::Archived);

        session()->flash('status', 'Product saved');
        $this->redirectRoute('admin.products.index', navigate: true);
    }

    public function render(): mixed
    {
        return view('livewire.admin.products.form', [
            'availableCollections' => Collection::query()->orderBy('title')->get(),
            'isEditing' => $this->product !== null,
        ])->layout('layouts.app', [
            'title' => $this->product ? $this->product->title : __('Add product'),
        ]);
    }

    private function fillFromProduct(Product $product): void
    {
        $this->title = $product->title;
        $this->descriptionHtml = (string) $product->description_html;
        $this->status = $product->status->value;
        $this->vendor = (string) $product->vendor;
        $this->productType = (string) $product->product_type;
        $this->tags = implode(', ', $product->tags ?? []);
        $this->handle = $product->handle;
        $this->publishedAt = $product->published_at?->format('Y-m-d\TH:i');
        $this->collectionIds = $product->collections->pluck('id')->map(fn (int $id): int => $id)->all();
        $this->media = $product->media
            ->map(fn (ProductMedia $media): array => [
                'id' => $media->getKey(),
                'url' => Storage::disk('public')->url($media->storage_key),
                'exists' => Storage::disk('public')->exists($media->storage_key),
                'altText' => (string) $media->alt_text,
                'position' => $media->position,
                'status' => $media->status->value,
            ])
            ->values()
            ->all();
        $this->options = $product->options
            ->map(fn (ProductOption $option): array => [
                'name' => $option->name,
                'values' => $option->values->pluck('value')->implode(', '),
            ])
            ->all();
        $this->variants = $product->variants
            ->map(fn (ProductVariant $variant): array => [
                'id' => $variant->getKey(),
                'label' => $variant->optionValues->isEmpty()
                    ? 'Default'
                    : $variant->optionValues->map(fn (ProductOptionValue $value): string => $value->value)->implode(' / '),
                'sku' => (string) $variant->sku,
                'price' => number_format($variant->price_amount / 100, 2, '.', ''),
                'compareAtPrice' => $variant->compare_at_amount ? number_format($variant->compare_at_amount / 100, 2, '.', '') : '',
                'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0,
                'requiresShipping' => $variant->requires_shipping,
            ])
            ->values()
            ->all();
    }

    /**
     * @return array<string, mixed>
     */
    private function productPayload(): array
    {
        return [
            'title' => $this->title,
            'description_html' => $this->descriptionHtml ?: null,
            'status' => $this->status,
            'vendor' => $this->vendor ?: null,
            'product_type' => $this->productType ?: null,
            'tags' => collect(explode(',', $this->tags))
                ->map(fn (string $tag): string => trim($tag))
                ->filter()
                ->values()
                ->all(),
            'handle' => Str::slug($this->handle),
        ];
    }

    /**
     * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
     */
    private function optionPayload(): array
    {
        return collect($this->options)
            ->map(function (array $option, int $position): array {
                return [
                    'name' => $option['name'],
                    'position' => $position,
                    'values' => collect(explode(',', $option['values']))
                        ->map(fn (string $value): string => trim($value))
                        ->filter()
                        ->values()
                        ->map(fn (string $value, int $valuePosition): array => [
                            'value' => $value,
                            'position' => $valuePosition,
                        ])
                        ->all(),
                ];
            })
            ->filter(fn (array $option): bool => $option['name'] !== '' && $option['values'] !== [])
            ->values()
            ->all();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function variantPayload(Store $store): array
    {
        return collect($this->variants)
            ->map(fn (array $variant, int $position): array => [
                'sku' => $variant['sku'] ?: null,
                'price_amount' => Money::fromDecimalString($variant['price']),
                'compare_at_amount' => $variant['compareAtPrice'] === '' ? null : Money::fromDecimalString($variant['compareAtPrice']),
                'currency' => $store->default_currency,
                'quantity_on_hand' => (int) $variant['quantity'],
                'requires_shipping' => (bool) $variant['requiresShipping'],
                'is_default' => $position === 0,
                'position' => $position,
            ])
            ->all();
    }

    private function syncExistingVariants(Product $product, Store $store): void
    {
        foreach ($this->variantPayload($store) as $position => $variantData) {
            $variantId = $this->variants[$position]['id'] ?? null;
            $variant = $variantId
                ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId)
                : $product->variants()->create(['position' => $position]);

            $variant->forceFill([
                'sku' => $variantData['sku'],
                'price_amount' => $variantData['price_amount'],
                'compare_at_amount' => $variantData['compare_at_amount'],
                'currency' => $variantData['currency'],
                'requires_shipping' => $variantData['requires_shipping'],
                'is_default' => $position === 0,
                'position' => $position,
            ])->save();

            InventoryItem::withoutGlobalScopes()->updateOrCreate(
                ['variant_id' => $variant->getKey()],
                [
                    'store_id' => $store->getKey(),
                    'quantity_on_hand' => $variantData['quantity_on_hand'],
                    'quantity_reserved' => 0,
                    'policy' => 'deny',
                ],
            );
        }
    }

    private function validateActiveVariantPricing(): bool
    {
        if ($this->status !== ProductStatus::Active->value) {
            return true;
        }

        $hasPricedVariant = collect($this->variants)
            ->contains(fn (array $variant): bool => Money::fromDecimalString($variant['price']) > 0);

        if ($hasPricedVariant) {
            return true;
        }

        $this->addError('variants.0.price', __('At least one priced variant is required before activation.'));

        return false;
    }

    private function validateVariantSkus(Store $store): bool
    {
        $skus = collect($this->variants)
            ->pluck('sku')
            ->map(fn (?string $sku): string => trim((string) $sku))
            ->filter()
            ->values();

        if ($skus->isEmpty()) {
            return true;
        }

        if ($skus->duplicates()->isNotEmpty()) {
            $this->addError('variants.0.sku', __('Each variant SKU must be unique for this store.'));

            return false;
        }

        $variantIds = collect($this->variants)
            ->pluck('id')
            ->filter()
            ->map(fn (mixed $variantId): int => (int) $variantId)
            ->values()
            ->all();

        $conflictingSku = ProductVariant::withoutGlobalScopes()
            ->whereIn('sku', $skus->all())
            ->whereHas('product', function ($query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            })
            ->when($variantIds !== [], fn ($query) => $query->whereKeyNot($variantIds))
            ->value('sku');

        if ($conflictingSku === null) {
            return true;
        }

        $this->addError('variants.0.sku', __('The SKU [:sku] is already used in this store.', ['sku' => $conflictingSku]));

        return false;
    }

    private function storeNewMedia(Product $product): void
    {
        $maxPosition = ProductMedia::withoutGlobalScopes()
            ->where('product_id', $product->getKey())
            ->max('position');
        $position = $maxPosition === null ? 0 : ((int) $maxPosition) + 1;

        foreach ($this->newMedia as $file) {
            $extension = strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: 'jpg');
            $storageKey = $file->storeAs(
                "media/originals/{$product->getKey()}",
                Str::uuid().'.'.$extension,
                'public',
            );

            $media = ProductMedia::withoutGlobalScopes()->create([
                'product_id' => $product->getKey(),
                'type' => MediaType::Image,
                'storage_key' => $storageKey,
                'alt_text' => $product->title,
                'position' => $position++,
                'status' => MediaStatus::Processing,
            ]);

            ProcessMediaUpload::dispatch($media->getKey(), (int) $product->store_id);
        }

        $this->reset('newMedia');
    }

    private function productForAction(): Product
    {
        $store = app('current_store');

        abort_unless($store instanceof Store && $this->product instanceof Product, 404);

        return Product::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->findOrFail($this->product->getKey());
    }

    private function mediaRecord(int $mediaId): ProductMedia
    {
        return ProductMedia::withoutGlobalScopes()
            ->where('product_id', $this->productForAction()->getKey())
            ->findOrFail($mediaId);
    }

    private function mediaIndex(int $mediaId): int
    {
        $index = collect($this->media)->search(fn (array $media): bool => $media['id'] === $mediaId);

        abort_if($index === false, 404);

        return (int) $index;
    }

    private function refreshProductMedia(): void
    {
        $this->product = $this->productForAction()->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'collections', 'media']);
        $this->fillFromProduct($this->product);
    }
}


command: sed -n '100,190p' resources/views/livewire/admin/products/form.blade.php

form.blade.php

type: read

aggregated_output:
                            No media for this product.

                    @endforelse

                    <flux:heading size="lg">Variants</flux:heading>
                    <flux:text>{{ count($variants) }} {{ Str::plural('variant', count($variants)) }}</flux:text>

                                Variant
                                SKU
                                Price
                                Compare
                                Qty
                                Ship

                            @foreach ($variants as $index => $variant)

                                    {{ $variant['label'] }}

                                        <flux:input wire:model="variants.{{ $index }}.sku" aria-label="Variant SKU" />

                                        <flux:input wire:model="variants.{{ $index }}.price" type="number" step="0.01" min="0" aria-label="Variant price" />

                                        <flux:input wire:model="variants.{{ $index }}.compareAtPrice" type="number" step="0.01" min="0" aria-label="Compare at price" />

                                        <flux:input wire:model="variants.{{ $index }}.quantity" type="number" min="0" aria-label="Variant quantity" />

                                        <flux:checkbox wire:model="variants.{{ $index }}.requiresShipping" aria-label="Requires shipping" />

                            @endforeach

                    <flux:heading size="lg">Options</flux:heading>
                    <flux:button type="button" wire:click="addOption" variant="ghost" icon="plus">Add option</flux:button>

                    @forelse ($options as $index => $option)

                            <flux:input wire:model="options.{{ $index }}.name" label="Option name" placeholder="Size" />
                            <flux:input wire:model="options.{{ $index }}.values" label="Values" placeholder="S, M, L, XL" />

                                <flux:button type="button" wire:click="removeOption({{ $index }})" variant="ghost" icon="trash">Remove</flux:button>

                    @empty
                        <flux:text>No options for this product.</flux:text>
                    @endforelse

                    <flux:select wire:model="status" label="Status">
                        <flux:select.option value="draft">Draft</flux:select.option>
                        <flux:select.option value="active">Active</flux:select.option>
                        <flux:select.option value="archived">Archived</flux:select.option>
                    </flux:select>

                    <flux:input wire:model="handle" label="URL handle" placeholder="short-sleeve-t-shirt" />
                    <flux:error name="handle" />

                    <flux:input wire:model="vendor" label="Vendor" placeholder="Acme Basics" />
                    <flux:input wire:model="productType" label="Product type" placeholder="T-Shirts" />

status: completed


output:
Chunk ID: d4d696
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1425
Output:

                            No media for this product.

                    @endforelse

                    <flux:heading size="lg">Variants</flux:heading>
                    <flux:text>{{ count($variants) }} {{ Str::plural('variant', count($variants)) }}</flux:text>

                                Variant
                                SKU
                                Price
                                Compare
                                Qty
                                Ship

                            @foreach ($variants as $index => $variant)

                                    {{ $variant['label'] }}

                                        <flux:input wire:model="variants.{{ $index }}.sku" aria-label="Variant SKU" />

                                        <flux:input wire:model="variants.{{ $index }}.price" type="number" step="0.01" min="0" aria-label="Variant price" />

                                        <flux:input wire:model="variants.{{ $index }}.compareAtPrice" type="number" step="0.01" min="0" aria-label="Compare at price" />

                                        <flux:input wire:model="variants.{{ $index }}.quantity" type="number" min="0" aria-label="Variant quantity" />

                                        <flux:checkbox wire:model="variants.{{ $index }}.requiresShipping" aria-label="Requires shipping" />

                            @endforeach

                    <flux:heading size="lg">Options</flux:heading>
                    <flux:button type="button" wire:click="addOption" variant="ghost" icon="plus">Add option</flux:button>

                    @forelse ($options as $index => $option)

                            <flux:input wire:model="options.{{ $index }}.name" label="Option name" placeholder="Size" />
                            <flux:input wire:model="options.{{ $index }}.values" label="Values" placeholder="S, M, L, XL" />

                                <flux:button type="button" wire:click="removeOption({{ $index }})" variant="ghost" icon="trash">Remove</flux:button>

                    @empty
                        <flux:text>No options for this product.</flux:text>
                    @endforelse

                    <flux:select wire:model="status" label="Status">
                        <flux:select.option value="draft">Draft</flux:select.option>
                        <flux:select.option value="active">Active</flux:select.option>
                        <flux:select.option value="archived">Archived</flux:select.option>
                    </flux:select>

                    <flux:input wire:model="handle" label="URL handle" placeholder="short-sleeve-t-shirt" />
                    <flux:error name="handle" />

                    <flux:input wire:model="vendor" label="Vendor" placeholder="Acme Basics" />
                    <flux:input wire:model="productType" label="Product type" placeholder="T-Shirts" />


command: sed -n '1,170p' tests/Feature/Catalog/CatalogUiTest.php

CatalogUiTest.php

aggregated_output:
<?php

use App\Livewire\Admin\Collections\Form as CollectionForm;
use App\Livewire\Admin\Products\Form as ProductForm;
use App\Livewire\Admin\Products\Index as ProductIndex;
use App\Models\Collection;
use App\Models\Product;
use App\Models\Store;
use App\Models\User;
use App\Services\ProductService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

test('storefront catalog browsing routes render seeded products', function () {
    $this->withHeader('Host', 'shop.test')
        ->get('/')
        ->assertSuccessful()
        ->assertSee('Acme Fashion')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99 EUR');

    $this->withHeader('Host', 'shop.test')
        ->get('/collections/t-shirts')
        ->assertSuccessful()
        ->assertSee('T-Shirts')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertDontSee('Unreleased Winter Jacket');

    $this->withHeader('Host', 'shop.test')
        ->get('/products/classic-cotton-t-shirt')
        ->assertSuccessful()
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('Size')
        ->assertSee('Color')
        ->assertSee('Add to cart');

    $this->withHeader('Host', 'shop.test')
        ->get('/search?q=draft')
        ->assertSuccessful()
        ->assertDontSee('Unreleased Winter Jacket');
});

test('admin catalog routes require authentication and render for store admins', function () {
    $this->get('/admin/products')->assertRedirect('/admin/login');

    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

    $this->actingAs($user)
        ->get('/admin/products')
        ->assertSuccessful()
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('Premium Slim Fit Jeans');

    $this->actingAs($user)
        ->get('/admin/collections')
        ->assertSuccessful()
        ->assertSee('Collections')
        ->assertSee('T-Shirts');

    $this->actingAs($user)
        ->get('/admin/inventory')
        ->assertSuccessful()
        ->assertSee('Inventory')
        ->assertSee('Limited Edition Sneakers');
});

test('admin product form creates edits archives and index filters products', function () {
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();
    app()->instance('current_store', $store);

    Livewire::actingAs($user)
        ->test(ProductForm::class)
        ->set('title', 'Test Product Created by E2E')
        ->set('handle', 'test-product-created-by-e2e')
        ->set('descriptionHtml', 'This product was created by the E2E test suite.')
        ->set('vendor', 'Test Vendor')
        ->set('productType', 'T-Shirts')
        ->set('variants.0.price', '29.99')
        ->set('variants.0.sku', 'E2E-TEST-001')
        ->set('variants.0.quantity', 50)
        ->call('save')
        ->assertSee('Product saved');

    $product = Product::query()->where('handle', 'test-product-created-by-e2e')->firstOrFail();

    expect($product->variants()->first()?->price_amount)->toBe(2999)
        ->and($product->variants()->first()?->inventoryItem?->quantity_on_hand)->toBe(50);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product])
        ->set('title', 'Test Product Updated')
        ->call('save')
        ->assertSee('Product saved');

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product->refresh()])
        ->set('variants.0.sku', 'ACME-CTSH-S-WHT')
        ->call('save')
        ->assertHasErrors(['variants.0.sku']);

    expect($product->refresh()->variants()->first()?->sku)->toBe('E2E-TEST-001');

    $draftProduct = app(ProductService::class)->create($store, [
        'title' => 'Draft Product With Price Later',
        'handle' => 'draft-product-with-price-later',
        'status' => 'draft',
        'variants' => [[
            'sku' => 'DRAFT-PRICE-LATER-001',
            'price_amount' => 0,
            'currency' => $store->default_currency,
            'quantity_on_hand' => 4,
            'is_default' => true,
            'position' => 0,
        ]],
    ]);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $draftProduct])
        ->set('variants.0.price', '17.50')
        ->set('status', 'active')
        ->call('save')
        ->assertSee('Product saved');

    expect($draftProduct->refresh()->status->value)->toBe('active')
        ->and($draftProduct->variants()->first()?->price_amount)->toBe(1750);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product->refresh()])
        ->set('status', 'archived')
        ->call('save')
        ->assertSee('Product saved');

    expect($product->refresh()->status->value)->toBe('archived');

    Livewire::actingAs($user)
        ->test(ProductIndex::class)
        ->set('statusFilter', 'draft')
        ->assertSee('Unreleased Winter Jacket')
        ->assertDontSee('Classic Cotton T-Shirt')
        ->set('statusFilter', 'active')
        ->set('search', 'Cotton')
        ->assertSee('Classic Cotton T-Shirt');
});

test('admin collection form creates and assigns products', function () {
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();
    $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail();
    app()->instance('current_store', $store);

    Livewire::actingAs($user)
        ->test(CollectionForm::class)
        ->set('title', 'E2E Test Collection')
        ->set('handle', 'e2e-test-collection')
        ->set('descriptionHtml', '
Created from tests.
')
        ->call('addProduct', $product->getKey())
        ->call('save')
        ->assertSee('Collection saved');

    $collection = Collection::query()->where('handle', 'e2e-test-collection')->firstOrFail();


output:
Chunk ID: 075c10
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1524
Output:
<?php

use App\Livewire\Admin\Collections\Form as CollectionForm;
use App\Livewire\Admin\Products\Form as ProductForm;
use App\Livewire\Admin\Products\Index as ProductIndex;
use App\Models\Collection;
use App\Models\Product;
use App\Models\Store;
use App\Models\User;
use App\Services\ProductService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;

uses(RefreshDatabase::class);

beforeEach(function () {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

test('storefront catalog browsing routes render seeded products', function () {
    $this->withHeader('Host', 'shop.test')
        ->get('/')
        ->assertSuccessful()
        ->assertSee('Acme Fashion')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('24.99 EUR');

    $this->withHeader('Host', 'shop.test')
        ->get('/collections/t-shirts')
        ->assertSuccessful()
        ->assertSee('T-Shirts')
        ->assertSee('Classic Cotton T-Shirt')
        ->assertDontSee('Unreleased Winter Jacket');

    $this->withHeader('Host', 'shop.test')
        ->get('/products/classic-cotton-t-shirt')
        ->assertSuccessful()
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('Size')
        ->assertSee('Color')
        ->assertSee('Add to cart');

    $this->withHeader('Host', 'shop.test')
        ->get('/search?q=draft')
        ->assertSuccessful()
        ->assertDontSee('Unreleased Winter Jacket');
});

test('admin catalog routes require authentication and render for store admins', function () {
    $this->get('/admin/products')->assertRedirect('/admin/login');

    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

    $this->actingAs($user)
        ->get('/admin/products')
        ->assertSuccessful()
        ->assertSee('Classic Cotton T-Shirt')
        ->assertSee('Premium Slim Fit Jeans');

    $this->actingAs($user)
        ->get('/admin/collections')
        ->assertSuccessful()
        ->assertSee('Collections')
        ->assertSee('T-Shirts');

    $this->actingAs($user)
        ->get('/admin/inventory')
        ->assertSuccessful()
        ->assertSee('Inventory')
        ->assertSee('Limited Edition Sneakers');
});

test('admin product form creates edits archives and index filters products', function () {
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();
    app()->instance('current_store', $store);

    Livewire::actingAs($user)
        ->test(ProductForm::class)
        ->set('title', 'Test Product Created by E2E')
        ->set('handle', 'test-product-created-by-e2e')
        ->set('descriptionHtml', 'This product was created by the E2E test suite.')
        ->set('vendor', 'Test Vendor')
        ->set('productType', 'T-Shirts')
        ->set('variants.0.price', '29.99')
        ->set('variants.0.sku', 'E2E-TEST-001')
        ->set('variants.0.quantity', 50)
        ->call('save')
        ->assertSee('Product saved');

    $product = Product::query()->where('handle', 'test-product-created-by-e2e')->firstOrFail();

    expect($product->variants()->first()?->price_amount)->toBe(2999)
        ->and($product->variants()->first()?->inventoryItem?->quantity_on_hand)->toBe(50);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product])
        ->set('title', 'Test Product Updated')
        ->call('save')
        ->assertSee('Product saved');

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product->refresh()])
        ->set('variants.0.sku', 'ACME-CTSH-S-WHT')
        ->call('save')
        ->assertHasErrors(['variants.0.sku']);

    expect($product->refresh()->variants()->first()?->sku)->toBe('E2E-TEST-001');

    $draftProduct = app(ProductService::class)->create($store, [
        'title' => 'Draft Product With Price Later',
        'handle' => 'draft-product-with-price-later',
        'status' => 'draft',
        'variants' => [[
            'sku' => 'DRAFT-PRICE-LATER-001',
            'price_amount' => 0,
            'currency' => $store->default_currency,
            'quantity_on_hand' => 4,
            'is_default' => true,
            'position' => 0,
        ]],
    ]);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $draftProduct])
        ->set('variants.0.price', '17.50')
        ->set('status', 'active')
        ->call('save')
        ->assertSee('Product saved');

    expect($draftProduct->refresh()->status->value)->toBe('active')
        ->and($draftProduct->variants()->first()?->price_amount)->toBe(1750);

    Livewire::actingAs($user)
        ->test(ProductForm::class, ['product' => $product->refresh()])
        ->set('status', 'archived')
        ->call('save')
        ->assertSee('Product saved');

    expect($product->refresh()->status->value)->toBe('archived');

    Livewire::actingAs($user)
        ->test(ProductIndex::class)
        ->set('statusFilter', 'draft')
        ->assertSee('Unreleased Winter Jacket')
        ->assertDontSee('Classic Cotton T-Shirt')
        ->set('statusFilter', 'active')
        ->set('search', 'Cotton')
        ->assertSee('Classic Cotton T-Shirt');
});

test('admin collection form creates and assigns products', function () {
    $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();
    $product = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail();
    app()->instance('current_store', $store);

    Livewire::actingAs($user)
        ->test(CollectionForm::class)
        ->set('title', 'E2E Test Collection')
        ->set('handle', 'e2e-test-collection')
        ->set('descriptionHtml', '
Created from tests.
')
        ->call('addProduct', $product->getKey())
        ->call('save')
        ->assertSee('Collection saved');

    $collection = Collection::query()->where('handle', 'e2e-test-collection')->firstOrFail();


cmd: sed -n '1,180p' database/migrations/2026_05_03_213857_create_product_options_table.php && sed -n '1,180p' database/migrations/2026_05_03_213858_create_product_option_values_table.php && sed -n '1,140p' database/migrations/2026_05_03_213900_create_variant_option_values_table.php


cmd: sed -n '1,150p' app/Models/ProductOption.php && sed -n '1,150p' app/Models/ProductOptionValue.php && sed -n '1,180p' app/Models/ProductVariant.php


command: sed -n '1,180p' database/migrations/2026_05_03_213857_create_product_options_table.php && sed -n '1,180p' database/migrations/2026_05_03_213858_create_product_option_values_table.php && sed -n '1,140p' database/migrations/2026_05_03_213900_create_variant_option_values_table.php

aggregated_output:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_options', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->unsignedInteger('position')->default(0);

            $table->index('product_id');
            $table->unique(['product_id', 'position']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('product_options');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_option_values', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_option_id')->constrained()->cascadeOnDelete();
            $table->string('value');
            $table->unsignedInteger('position')->default(0);

            $table->index('product_option_id');
            $table->unique(['product_option_id', 'position']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('product_option_values');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('variant_option_values', function (Blueprint $table) {
            $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
            $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete();

            $table->primary(['variant_id', 'product_option_value_id']);
            $table->index('product_option_value_id');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('variant_option_values');
    }
};


output:
Chunk ID: ce718c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 606
Output:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_options', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->unsignedInteger('position')->default(0);

            $table->index('product_id');
            $table->unique(['product_id', 'position']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('product_options');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('product_option_values', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_option_id')->constrained()->cascadeOnDelete();
            $table->string('value');
            $table->unsignedInteger('position')->default(0);

            $table->index('product_option_id');
            $table->unique(['product_option_id', 'position']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('product_option_values');
    }
};
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('variant_option_values', function (Blueprint $table) {
            $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
            $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete();

            $table->primary(['variant_id', 'product_option_value_id']);
            $table->index('product_option_value_id');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('variant_option_values');
    }
};


command: sed -n '1,150p' app/Models/ProductOption.php && sed -n '1,150p' app/Models/ProductOptionValue.php && sed -n '1,180p' app/Models/ProductVariant.php

ProductVariant.php

aggregated_output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ProductOption extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'name',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return HasMany<ProductOptionValue, $this>
     */
    public function values(): HasMany
    {
        return $this->hasMany(ProductOptionValue::class)->orderBy('position');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class ProductOptionValue extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionValueFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_option_id',
        'value',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('option.product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<ProductOption, $this>
     */
    public function option(): BelongsTo
    {
        return $this->belongsTo(ProductOption::class, 'product_option_id');
    }

    /**
     * @return BelongsToMany<ProductVariant, $this>
     */
    public function variants(): BelongsToMany
    {
        return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}
<?php

namespace App\Models;

use App\Enums\InventoryPolicy;
use App\Enums\VariantStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use RuntimeException;

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'price_amount' => 0,
        'currency' => 'USD',
        'requires_shipping' => true,
        'is_default' => false,
        'position' => 0,
        'status' => 'active',
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });

        static::saving(function (ProductVariant $variant): void {
            $variant->assertSkuIsUniqueForStore();
        });

        static::created(function (ProductVariant $variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            $storeId = Product::withoutGlobalScopes()
                ->whereKey($variant->product_id)
                ->value('store_id');

            if (! $storeId) {
                return;
            }

            InventoryItem::withoutGlobalScopes()->create([
                'store_id' => $storeId,
                'variant_id' => $variant->getKey(),
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ]);
        });
    }

    private function assertSkuIsUniqueForStore(): void
    {
        $sku = trim((string) $this->sku);

        if ($sku === '') {
            return;
        }

        $storeId = Product::withoutGlobalScopes()
            ->whereKey($this->product_id)
            ->value('store_id');

        if ($storeId === null) {
            return;
        }

        $query = self::withoutGlobalScopes()
            ->where('sku', $sku)
            ->whereHas('product', function (Builder $query) use ($storeId): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $storeId);
            });

        if ($this->exists) {
            $query->whereKeyNot($this->getKey());
        }

        if ($query->exists()) {
            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
        }
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return HasOne<InventoryItem, $this>
     */
    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    /**
     * @return BelongsToMany<ProductOptionValue, $this>
     */
    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }

    public function isPurchasable(): bool
    {
        return $this->status === VariantStatus::Active;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
            'requires_shipping' => 'bool',
            'is_default' => 'bool',
            'position' => 'integer',
            'status' => VariantStatus::class,
        ];
    }
}


output:
Chunk ID: 5e2155
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2074
Output:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ProductOption extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'name',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return HasMany<ProductOptionValue, $this>
     */
    public function values(): HasMany
    {
        return $this->hasMany(ProductOptionValue::class)->orderBy('position');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class ProductOptionValue extends Model
{
    /** @use HasFactory<\Database\Factories\ProductOptionValueFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_option_id',
        'value',
        'position',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'position' => 0,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('option.product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<ProductOption, $this>
     */
    public function option(): BelongsTo
    {
        return $this->belongsTo(ProductOption::class, 'product_option_id');
    }

    /**
     * @return BelongsToMany<ProductVariant, $this>
     */
    public function variants(): BelongsToMany
    {
        return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'position' => 'integer',
        ];
    }
}
<?php

namespace App\Models;

use App\Enums\InventoryPolicy;
use App\Enums\VariantStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use RuntimeException;

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'price_amount' => 0,
        'currency' => 'USD',
        'requires_shipping' => true,
        'is_default' => false,
        'position' => 0,
        'status' => 'active',
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('product', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });

        static::saving(function (ProductVariant $variant): void {
            $variant->assertSkuIsUniqueForStore();
        });

        static::created(function (ProductVariant $variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            $storeId = Product::withoutGlobalScopes()
                ->whereKey($variant->product_id)
                ->value('store_id');

            if (! $storeId) {
                return;
            }

            InventoryItem::withoutGlobalScopes()->create([
                'store_id' => $storeId,
                'variant_id' => $variant->getKey(),
                'quantity_on_hand' => 0,
                'quantity_reserved' => 0,
                'policy' => InventoryPolicy::Deny,
            ]);
        });
    }

    private function assertSkuIsUniqueForStore(): void
    {
        $sku = trim((string) $this->sku);

        if ($sku === '') {
            return;
        }

        $storeId = Product::withoutGlobalScopes()
            ->whereKey($this->product_id)
            ->value('store_id');

        if ($storeId === null) {
            return;
        }

        $query = self::withoutGlobalScopes()
            ->where('sku', $sku)
            ->whereHas('product', function (Builder $query) use ($storeId): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $storeId);
            });

        if ($this->exists) {
            $query->whereKeyNot($this->getKey());
        }

        if ($query->exists()) {
            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
        }
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return HasOne<InventoryItem, $this>
     */
    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    /**
     * @return BelongsToMany<ProductOptionValue, $this>
     */
    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }

    public function isPurchasable(): bool
    {
        return $this->status === VariantStatus::Active;
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
            'requires_shipping' => 'bool',
            'is_default' => 'bool',
            'position' => 'integer',
            'status' => VariantStatus::class,
        ];
    }
}


cmd: sed -n '1,260p' tests/Feature/Catalog/ProductServiceTest.php


command: sed -n '1,260p' tests/Feature/Catalog/ProductServiceTest.php

aggregated_output:
<?php

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\Product;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use App\Support\HandleGenerator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;

uses(RefreshDatabase::class);

test('handle generator creates unique handles per store', function () {
    $store = Store::factory()->create();

    Product::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Classic Cotton T-Shirt',
        'handle' => 'classic-cotton-t-shirt',
    ]);

    $handle = app(HandleGenerator::class)->generate('Classic Cotton T-Shirt', 'products', $store->getKey());

    expect($handle)->toBe('classic-cotton-t-shirt-1');
});

test('product service creates products with a default variant and inventory', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Merino Crew',
        'price_amount' => 4999,
        'tags' => ['new'],
    ]);

    expect($product->handle)->toBe('merino-crew')
        ->and($product->variants)->toHaveCount(1)
        ->and($product->variants->first()->price_amount)->toBe(4999)
        ->and($product->variants->first()->inventoryItem)->not->toBeNull();
});

test('product service routes requested active status through lifecycle checks', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    expect(fn () => app(ProductService::class)->create($store, [
        'title' => 'Unpriced Product',
        'status' => ProductStatus::Active,
        'price_amount' => 0,
    ]))->toThrow(InvalidProductTransitionException::class);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Priced Product',
        'status' => ProductStatus::Active,
        'price_amount' => 1000,
    ]);

    expect($product->status)->toBe(ProductStatus::Active)
        ->and($product->published_at)->not->toBeNull();
});

test('product service syncs variant option selections', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Optioned Product',
        'options' => [
            [
                'name' => 'Size',
                'position' => 0,
                'values' => [
                    ['value' => 'S', 'position' => 0],
                    ['value' => 'M', 'position' => 1],
                ],
            ],
            [
                'name' => 'Color',
                'position' => 1,
                'values' => [
                    ['value' => 'Black', 'position' => 0],
                    ['value' => 'White', 'position' => 1],
                ],
            ],
        ],
        'variants' => [
            [
                'sku' => 'OPT-S-BLK',
                'price_amount' => 1500,
                'status' => VariantStatus::Active,
                'options' => ['Size' => 'S', 'Color' => 'Black'],
            ],
        ],
    ]);

    $variant = $product->variants()->firstOrFail();
    $values = $variant->optionValues()
        ->with('option')
        ->get()
        ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value])
        ->all();

    expect($values)->toBe([
        'Size' => 'S',
        'Color' => 'Black',
    ]);
});

test('product status transitions enforce activation preconditions and dispatch events', function () {
    Event::fake([ProductStatusChanged::class]);

    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->draft()->create([
        'store_id' => $store->getKey(),
        'title' => 'Draft Product',
    ]);

    expect(fn () => app(ProductService::class)->transitionStatus($product, ProductStatus::Active))
        ->toThrow(InvalidProductTransitionException::class);

    ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 1200,
    ]);

    app(ProductService::class)->transitionStatus($product->refresh(), ProductStatus::Active);

    expect($product->refresh()->status)->toBe(ProductStatus::Active)
        ->and($product->published_at)->not->toBeNull();

    Event::assertDispatched(ProductStatusChanged::class);
});

test('duplicate non-empty skus are rejected within the same store', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    app(ProductService::class)->create($store, [
        'title' => 'First Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1000],
        ],
    ]);

    expect(fn () => app(ProductService::class)->create($store, [
        'title' => 'Second Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1200],
        ],
    ]))->toThrow(RuntimeException::class);
});


output:
Chunk ID: 93063f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1322
Output:
<?php

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\Product;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\ProductService;
use App\Support\HandleGenerator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;

uses(RefreshDatabase::class);

test('handle generator creates unique handles per store', function () {
    $store = Store::factory()->create();

    Product::factory()->create([
        'store_id' => $store->getKey(),
        'title' => 'Classic Cotton T-Shirt',
        'handle' => 'classic-cotton-t-shirt',
    ]);

    $handle = app(HandleGenerator::class)->generate('Classic Cotton T-Shirt', 'products', $store->getKey());

    expect($handle)->toBe('classic-cotton-t-shirt-1');
});

test('product service creates products with a default variant and inventory', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Merino Crew',
        'price_amount' => 4999,
        'tags' => ['new'],
    ]);

    expect($product->handle)->toBe('merino-crew')
        ->and($product->variants)->toHaveCount(1)
        ->and($product->variants->first()->price_amount)->toBe(4999)
        ->and($product->variants->first()->inventoryItem)->not->toBeNull();
});

test('product service routes requested active status through lifecycle checks', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    expect(fn () => app(ProductService::class)->create($store, [
        'title' => 'Unpriced Product',
        'status' => ProductStatus::Active,
        'price_amount' => 0,
    ]))->toThrow(InvalidProductTransitionException::class);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Priced Product',
        'status' => ProductStatus::Active,
        'price_amount' => 1000,
    ]);

    expect($product->status)->toBe(ProductStatus::Active)
        ->and($product->published_at)->not->toBeNull();
});

test('product service syncs variant option selections', function () {
    $store = Store::factory()->create(['default_currency' => 'EUR']);
    app()->instance('current_store', $store);

    $product = app(ProductService::class)->create($store, [
        'title' => 'Optioned Product',
        'options' => [
            [
                'name' => 'Size',
                'position' => 0,
                'values' => [
                    ['value' => 'S', 'position' => 0],
                    ['value' => 'M', 'position' => 1],
                ],
            ],
            [
                'name' => 'Color',
                'position' => 1,
                'values' => [
                    ['value' => 'Black', 'position' => 0],
                    ['value' => 'White', 'position' => 1],
                ],
            ],
        ],
        'variants' => [
            [
                'sku' => 'OPT-S-BLK',
                'price_amount' => 1500,
                'status' => VariantStatus::Active,
                'options' => ['Size' => 'S', 'Color' => 'Black'],
            ],
        ],
    ]);

    $variant = $product->variants()->firstOrFail();
    $values = $variant->optionValues()
        ->with('option')
        ->get()
        ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option->name => $value->value])
        ->all();

    expect($values)->toBe([
        'Size' => 'S',
        'Color' => 'Black',
    ]);
});

test('product status transitions enforce activation preconditions and dispatch events', function () {
    Event::fake([ProductStatusChanged::class]);

    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->draft()->create([
        'store_id' => $store->getKey(),
        'title' => 'Draft Product',
    ]);

    expect(fn () => app(ProductService::class)->transitionStatus($product, ProductStatus::Active))
        ->toThrow(InvalidProductTransitionException::class);

    ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 1200,
    ]);

    app(ProductService::class)->transitionStatus($product->refresh(), ProductStatus::Active);

    expect($product->refresh()->status)->toBe(ProductStatus::Active)
        ->and($product->published_at)->not->toBeNull();

    Event::assertDispatched(ProductStatusChanged::class);
});

test('duplicate non-empty skus are rejected within the same store', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    app(ProductService::class)->create($store, [
        'title' => 'First Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1000],
        ],
    ]);

    expect(fn () => app(ProductService::class)->create($store, [
        'title' => 'Second Product',
        'variants' => [
            ['sku' => 'DUP-1', 'price_amount' => 1200],
        ],
    ]))->toThrow(RuntimeException::class);
});


message: I’m going to keep the matrix change inside the existing product form: parse option rows, generate cartesian variant rows, preserve matching variant edits by option combination, and sync option-value pivots on save. This avoids introducing another service boundary while still using the existing ProductService creation path.

phase: commentary


type: message

role: assistant

text: I’m going to keep the matrix change inside the existing product form: parse option rows, generate cartesian variant rows, preserve matching variant edits by option combination, and sync option-value pivots on save. This avoids introducing another service boundary while still using the existing ProductService creation path.


status: completed

input:
*** Begin Patch
*** Update File: app/Livewire/Admin/Products/Form.php
@@
 use App\Enums\MediaStatus;
 use App\Enums\MediaType;
 use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
 use App\Jobs\ProcessMediaUpload;
@@
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Storage;
@@
-     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool}>
+     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool, options: array<string, string>}>
      */
     public array $variants = [];
@@
             'compareAtPrice' => '',
             'quantity' => 0,
             'requiresShipping' => true,
+            'options' => [],
         ]];
     }
@@
     public function removeOption(int $index): void
     {
         unset($this->options[$index]);
         $this->options = array_values($this->options);
     }

+    public function generateVariants(): void
+    {
+        $optionPayload = $this->optionPayload();
+
+        if ($optionPayload === []) {
+            $template = $this->variants[0] ?? [];
+            $this->variants = [[
+                'id' => $template['id'] ?? null,
+                'label' => 'Default',
+                'sku' => $template['sku'] ?? '',
+                'price' => $template['price'] ?? '0.00',
+                'compareAtPrice' => $template['compareAtPrice'] ?? '',
+                'quantity' => (int) ($template['quantity'] ?? 0),
+                'requiresShipping' => (bool) ($template['requiresShipping'] ?? true),
+                'options' => [],
+            ]];
+
+            return;
+        }
+
+        $existingVariants = collect($this->variants)
+            ->keyBy(fn (array $variant): string => $this->variantOptionKey($variant['options'] ?? []));
+        $template = $this->variants[0] ?? [];
+
+        $this->variants = collect($this->optionCombinations($optionPayload))
+            ->map(function (array $options) use ($existingVariants, $template): array {
+                $existingVariant = $existingVariants->get($this->variantOptionKey($options));
+
+                return [
+                    'id' => $existingVariant['id'] ?? null,
+                    'label' => $this->variantLabel($options),
+                    'sku' => $existingVariant['sku'] ?? '',
+                    'price' => $existingVariant['price'] ?? ($template['price'] ?? '0.00'),
+                    'compareAtPrice' => $existingVariant['compareAtPrice'] ?? ($template['compareAtPrice'] ?? ''),
+                    'quantity' => (int) ($existingVariant['quantity'] ?? 0),
+                    'requiresShipping' => (bool) ($existingVariant['requiresShipping'] ?? ($template['requiresShipping'] ?? true)),
+                    'options' => $options,
+                ];
+            })
+            ->values()
+            ->all();
+    }
+
     public function save(): void
     {
         $store = app('current_store');
         abort_unless($store instanceof Store, 404);

+        $this->ensureVariantMatrixMatchesOptions();
+
         $this->validate([
@@
                 $product = $productService->update($this->product, $payload);
+                $this->syncProductOptions($product);
                 $this->syncExistingVariants($product, $store);
@@
                 'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0,
                 'requiresShipping' => $variant->requires_shipping,
+                'options' => $variant->optionValues
+                    ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option?->name ?? 'Option' => $value->value])
+                    ->all(),
             ])
             ->values()
             ->all();
@@
                 'quantity_on_hand' => (int) $variant['quantity'],
                 'requires_shipping' => (bool) $variant['requiresShipping'],
                 'is_default' => $position === 0,
                 'position' => $position,
+                'options' => $variant['options'] ?? [],
             ])
             ->all();
     }

     private function syncExistingVariants(Product $product, Store $store): void
     {
+        $syncedVariantIds = [];
+
         foreach ($this->variantPayload($store) as $position => $variantData) {
             $variantId = $this->variants[$position]['id'] ?? null;
             $variant = $variantId
@@
             InventoryItem::withoutGlobalScopes()->updateOrCreate(
                 ['variant_id' => $variant->getKey()],
@@
                 ],
             );
+
+            $variant->optionValues()->sync($this->optionValueIdsForVariant($product, $variantData['options'] ?? []));
+            $syncedVariantIds[] = $variant->getKey();
         }
+
+        ProductVariant::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedVariantIds !== [], fn ($query) => $query->whereNotIn('id', $syncedVariantIds))
+            ->get()
+            ->each(function (ProductVariant $variant): void {
+                if ($this->variantHasOrderLines($variant)) {
+                    $variant->forceFill(['status' => VariantStatus::Archived])->save();
+
+                    return;
+                }
+
+                $variant->delete();
+            });
     }
+
+    private function syncProductOptions(Product $product): void
+    {
+        $optionPayload = $this->optionPayload();
+        $syncedOptionIds = [];
+
+        foreach ($optionPayload as $optionData) {
+            $option = ProductOption::withoutGlobalScopes()
+                ->where('product_id', $product->getKey())
+                ->where('position', $optionData['position'])
+                ->first()
+                ?? $product->options()->create([
+                    'name' => $optionData['name'],
+                    'position' => $optionData['position'],
+                ]);
+
+            $option->forceFill([
+                'name' => $optionData['name'],
+                'position' => $optionData['position'],
+            ])->save();
+
+            $syncedOptionIds[] = $option->getKey();
+            $syncedValueIds = [];
+
+            foreach ($optionData['values'] as $valueData) {
+                $value = ProductOptionValue::withoutGlobalScopes()
+                    ->where('product_option_id', $option->getKey())
+                    ->where('position', $valueData['position'])
+                    ->first()
+                    ?? $option->values()->create([
+                        'value' => $valueData['value'],
+                        'position' => $valueData['position'],
+                    ]);
+
+                $value->forceFill([
+                    'value' => $valueData['value'],
+                    'position' => $valueData['position'],
+                ])->save();
+
+                $syncedValueIds[] = $value->getKey();
+            }
+
+            ProductOptionValue::withoutGlobalScopes()
+                ->where('product_option_id', $option->getKey())
+                ->when($syncedValueIds !== [], fn ($query) => $query->whereNotIn('id', $syncedValueIds))
+                ->delete();
+        }
+
+        ProductOption::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedOptionIds !== [], fn ($query) => $query->whereNotIn('id', $syncedOptionIds))
+            ->delete();
+    }
*** End Patch


stdout:
Success. Updated the following files:
M app/Livewire/Admin/Products/Form.php

type: update

unified_diff:
@@ -7,2 +7,3 @@
 use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
 use App\Jobs\ProcessMediaUpload;
@@ -19,2 +20,3 @@
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Storage;
@@ -58,3 +60,3 @@
     /**
-     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool}>
+     * @var array<int, array{id: int|null, label: string, sku: string, price: string, compareAtPrice: string, quantity: int, requiresShipping: bool, options: array<string, string>}>
      */
@@ -93,2 +95,3 @@
             'requiresShipping' => true,
+            'options' => [],
         ]];
@@ -117,2 +120,45 @@

+    public function generateVariants(): void
+    {
+        $optionPayload = $this->optionPayload();
+
+        if ($optionPayload === []) {
+            $template = $this->variants[0] ?? [];
+            $this->variants = [[
+                'id' => $template['id'] ?? null,
+                'label' => 'Default',
+                'sku' => $template['sku'] ?? '',
+                'price' => $template['price'] ?? '0.00',
+                'compareAtPrice' => $template['compareAtPrice'] ?? '',
+                'quantity' => (int) ($template['quantity'] ?? 0),
+                'requiresShipping' => (bool) ($template['requiresShipping'] ?? true),
+                'options' => [],
+            ]];
+
+            return;
+        }
+
+        $existingVariants = collect($this->variants)
+            ->keyBy(fn (array $variant): string => $this->variantOptionKey($variant['options'] ?? []));
+        $template = $this->variants[0] ?? [];
+
+        $this->variants = collect($this->optionCombinations($optionPayload))
+            ->map(function (array $options) use ($existingVariants, $template): array {
+                $existingVariant = $existingVariants->get($this->variantOptionKey($options));
+
+                return [
+                    'id' => $existingVariant['id'] ?? null,
+                    'label' => $this->variantLabel($options),
+                    'sku' => $existingVariant['sku'] ?? '',
+                    'price' => $existingVariant['price'] ?? ($template['price'] ?? '0.00'),
+                    'compareAtPrice' => $existingVariant['compareAtPrice'] ?? ($template['compareAtPrice'] ?? ''),
+                    'quantity' => (int) ($existingVariant['quantity'] ?? 0),
+                    'requiresShipping' => (bool) ($existingVariant['requiresShipping'] ?? ($template['requiresShipping'] ?? true)),
+                    'options' => $options,
+                ];
+            })
+            ->values()
+            ->all();
+    }
+
     public function save(): void
@@ -122,2 +168,4 @@

+        $this->ensureVariantMatrixMatchesOptions();
+
         $this->validate([
@@ -166,2 +214,3 @@
                 $product = $productService->update($this->product, $payload);
+                $this->syncProductOptions($product);
                 $this->syncExistingVariants($product, $store);
@@ -355,2 +404,5 @@
                 'requiresShipping' => $variant->requires_shipping,
+                'options' => $variant->optionValues
+                    ->mapWithKeys(fn (ProductOptionValue $value): array => [$value->option?->name ?? 'Option' => $value->value])
+                    ->all(),
             ])
@@ -421,2 +473,3 @@
                 'position' => $position,
+                'options' => $variant['options'] ?? [],
             ])
@@ -427,2 +480,4 @@
     {
+        $syncedVariantIds = [];
+
         foreach ($this->variantPayload($store) as $position => $variantData) {
@@ -452,3 +507,73 @@
             );
+
+            $variant->optionValues()->sync($this->optionValueIdsForVariant($product, $variantData['options'] ?? []));
+            $syncedVariantIds[] = $variant->getKey();
         }
+
+        ProductVariant::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedVariantIds !== [], fn ($query) => $query->whereNotIn('id', $syncedVariantIds))
+            ->get()
+            ->each(function (ProductVariant $variant): void {
+                if ($this->variantHasOrderLines($variant)) {
+                    $variant->forceFill(['status' => VariantStatus::Archived])->save();
+
+                    return;
+                }
+
+                $variant->delete();
+            });
+    }
+
+    private function syncProductOptions(Product $product): void
+    {
+        $optionPayload = $this->optionPayload();
+        $syncedOptionIds = [];
+
+        foreach ($optionPayload as $optionData) {
+            $option = ProductOption::withoutGlobalScopes()
+                ->where('product_id', $product->getKey())
+                ->where('position', $optionData['position'])
+                ->first()
+                ?? $product->options()->create([
+                    'name' => $optionData['name'],
+                    'position' => $optionData['position'],
+                ]);
+
+            $option->forceFill([
+                'name' => $optionData['name'],
+                'position' => $optionData['position'],
+            ])->save();
+
+            $syncedOptionIds[] = $option->getKey();
+            $syncedValueIds = [];
+
+            foreach ($optionData['values'] as $valueData) {
+                $value = ProductOptionValue::withoutGlobalScopes()
+                    ->where('product_option_id', $option->getKey())
+                    ->where('position', $valueData['position'])
+                    ->first()
+                    ?? $option->values()->create([
+                        'value' => $valueData['value'],
+                        'position' => $valueData['position'],
+                    ]);
+
+                $value->forceFill([
+                    'value' => $valueData['value'],
+                    'position' => $valueData['position'],
+                ])->save();
+
+                $syncedValueIds[] = $value->getKey();
+            }
+
+            ProductOptionValue::withoutGlobalScopes()
+                ->where('product_option_id', $option->getKey())
+                ->when($syncedValueIds !== [], fn ($query) => $query->whereNotIn('id', $syncedValueIds))
+                ->delete();
+        }
+
+        ProductOption::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedOptionIds !== [], fn ($query) => $query->whereNotIn('id', $syncedOptionIds))
+            ->delete();
     }


output: {"output":"Success. Updated the following files:\nM app/Livewire/Admin/Products/Form.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}