The factories now have useful defaults and states. I’m adding deterministic catalog seed data next, including the named fixtures required by the E2E plan: Classic Cotton T-Shirt, draft product, sold-out deny-policy product, and backorder continue-policy product.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/seeders/CollectionSeeder.php
@@
 namespace Database\Seeders;

-use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use App\Models\Collection;
+use App\Models\Store;
 use Illuminate\Database\Seeder;
@@
     public function run(): void
     {
-        //
+        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+
+        $collections = [
+            ['title' => 'T-Shirts', 'handle' => 't-shirts', 'description_html' => '
Everyday cotton essentials.
'],
+            ['title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description_html' => '
Fresh pieces just landed.
'],
+            ['title' => 'Sale', 'handle' => 'sale', 'description_html' => '
Limited-time offers.
'],
+            ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '
Finishing touches for every order.
'],
+        ];
+
+        foreach ($collections as $collection) {
+            Collection::query()->updateOrCreate(
+                [
+                    'store_id' => $store->getKey(),
+                    'handle' => $collection['handle'],
+                ],
+                [
+                    'title' => $collection['title'],
+                    'description_html' => $collection['description_html'],
+                    'type' => 'manual',
+                    'status' => 'active',
+                ],
+            );
+        }
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php
@@
 namespace Database\Seeders;

-use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use App\Models\Collection as ProductCollection;
+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 Illuminate\Database\Seeder;
+use Illuminate\Support\Arr;

 class ProductSeeder extends Seeder
 {
-    /**
-     * Run the database seeds.
-     */
     public function run(): void
     {
-        //
+        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+        $collections = ProductCollection::query()
+            ->where('store_id', $store->getKey())
+            ->get()
+            ->keyBy('handle');
+
+        foreach ($this->products() as $index => $data) {
+            $product = Product::query()->updateOrCreate(
+                [
+                    'store_id' => $store->getKey(),
+                    'handle' => $data['handle'],
+                ],
+                [
+                    ...Arr::only($data, ['title', 'status', 'description_html', 'vendor', 'product_type', 'tags', 'published_at']),
+                    'store_id' => $store->getKey(),
+                ],
+            );
+
+            $this->createOptions($product, $data['options'] ?? []);
+            $this->createVariants($store, $product, $data['variants']);
+            $this->createMedia($product);
+
+            $collectionIds = collect($data['collections'] ?? [])
+                ->map(fn (string $handle): ?int => $collections->get($handle)?->getKey())
+                ->filter()
+                ->mapWithKeys(fn (int $collectionId): array => [$collectionId => ['position' => $index]])
+                ->all();
+
+            $product->collections()->syncWithoutDetaching($collectionIds);
+        }
     }
+
+    /**
+     * @param  array<string, array<int, string>>  $options
+     */
+    private function createOptions(Product $product, array $options): void
+    {
+        foreach (array_values($options) as $optionPosition => $valuesByName) {
+            $option = ProductOption::query()->updateOrCreate(
+                [
+                    'product_id' => $product->getKey(),
+                    'position' => $optionPosition,
+                ],
+                [
+                    'name' => array_key_first($valuesByName),
+                ],
+            );
+
+            foreach (array_values($valuesByName[array_key_first($valuesByName)]) as $valuePosition => $value) {
+                ProductOptionValue::query()->updateOrCreate(
+                    [
+                        'product_option_id' => $option->getKey(),
+                        'position' => $valuePosition,
+                    ],
+                    ['value' => $value],
+                );
+            }
+        }
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $variants
+     */
+    private function createVariants(Store $store, Product $product, array $variants): void
+    {
+        foreach ($variants as $position => $variantData) {
+            $variant = ProductVariant::query()->updateOrCreate(
+                [
+                    'product_id' => $product->getKey(),
+                    'position' => $position,
+                ],
+                [
+                    'sku' => $variantData['sku'] ?? null,
+                    'barcode' => $variantData['barcode'] ?? null,
+                    'price_amount' => $variantData['price_amount'],
+                    'compare_at_amount' => $variantData['compare_at_amount'] ?? null,
+                    'currency' => $store->default_currency,
+                    'weight_g' => $variantData['weight_g'] ?? 250,
+                    'requires_shipping' => $variantData['requires_shipping'] ?? true,
+                    'is_default' => $position === 0,
+                    'status' => $variantData['status'] ?? 'active',
+                ],
+            );
+
+            InventoryItem::query()->updateOrCreate(
+                ['variant_id' => $variant->getKey()],
+                [
+                    'store_id' => $store->getKey(),
+                    'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 50,
+                    'quantity_reserved' => 0,
+                    'policy' => $variantData['policy'] ?? 'deny',
+                ],
+            );
+        }
+    }
+
+    private function createMedia(Product $product): void
+    {
+        ProductMedia::query()->updateOrCreate(
+            [
+                'product_id' => $product->getKey(),
+                'position' => 0,
+            ],
+            [
+                'type' => 'image',
+                'storage_key' => 'products/'.$product->handle.'.jpg',
+                'alt_text' => $product->title,
+                'width' => 1200,
+                'height' => 1200,
+                'mime_type' => 'image/jpeg',
+                'byte_size' => 180000,
+                'status' => 'ready',
+            ],
+        );
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    private function products(): array
+    {
+        $base = [
+            [
+                'title' => 'Classic Cotton T-Shirt',
+                'handle' => 'classic-cotton-t-shirt',
+                'product_type' => 'Shirts',
+                'tags' => ['new', 'popular'],
+                'collections' => ['t-shirts', 'new-arrivals'],
+                'options' => [
+                    ['Size' => ['S', 'M', 'L', 'XL']],
+                    ['Color' => ['Black', 'White', 'Navy']],
+                ],
+                'variants' => [
+                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                ],
+            ],
+            [
+                'title' => 'Premium Slim Fit Jeans',
+                'handle' => 'premium-slim-fit-jeans',
+                'product_type' => 'Pants',
+                'tags' => ['sale', 'popular'],
+                'collections' => ['new-arrivals', 'sale'],
+                'variants' => [
+                    ['sku' => 'JNS-SLM-32', 'price_amount' => 7999, 'compare_at_amount' => 9999, 'quantity_on_hand' => 35],
+                ],
+            ],
+        ];
+
+        for ($i = 3; $i <= 20; $i++) {
+            $base[] = [
+                'title' => match ($i) {
+                    15 => 'Draft Linen Overshirt',
+                    17 => 'Sold Out Canvas Tote',
+                    18 => 'Backorder Wool Scarf',
+                    default => "Acme Catalog Product {$i}",
+                },
+                'handle' => match ($i) {
+                    15 => 'draft-linen-overshirt',
+                    17 => 'sold-out-canvas-tote',
+                    18 => 'backorder-wool-scarf',
+                    default => "acme-catalog-product-{$i}",
+                },
+                'status' => $i === 15 ? 'draft' : 'active',
+                'published_at' => $i === 15 ? null : now(),
+                'product_type' => $i % 2 === 0 ? 'Accessories' : 'Shirts',
+                'tags' => $i % 2 === 0 ? ['new'] : ['trending'],
+                'collections' => $i % 2 === 0 ? ['accessories'] : ['new-arrivals'],
+                'variants' => [[
+                    'sku' => "ACME-{$i}",
+                    'price_amount' => $i === 17 ? 1999 : 2999 + ($i * 100),
+                    'quantity_on_hand' => in_array($i, [17, 18], true) ? 0 : 20 + $i,
+                    'policy' => $i === 18 ? 'continue' : 'deny',
+                ]],
+            ];
+        }
+
+        return array_map(function (array $product): array {
+            return [
+                'status' => 'active',
+                'description_html' => '
Built for the Acme Fashion demo catalog.
',
+                'vendor' => 'Acme',
+                'published_at' => now(),
+                ...$product,
+            ];
+        }, $base);
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/database/seeders/DatabaseSeeder.php
@@
             StoreUserSeeder::class,
             StoreSettingsSeeder::class,
+            CollectionSeeder::class,
+            ProductSeeder::class,
             CustomerSeeder::class,
         ]);
     }
 }
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/seeders/CollectionSeeder.php
M /Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php
M /Users/fabianwesner/Herd/shop/database/seeders/DatabaseSeeder.php

@@ -20,2 +20,4 @@
             StoreSettingsSeeder::class,
+            CollectionSeeder::class,
+            ProductSeeder::class,
             CustomerSeeder::class,

@@ -4,3 +4,4 @@

-use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use App\Models\Collection;
+use App\Models\Store;
 use Illuminate\Database\Seeder;
@@ -14,3 +15,25 @@
     {
-        //
+        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+
+        $collections = [
+            ['title' => 'T-Shirts', 'handle' => 't-shirts', 'description_html' => '
Everyday cotton essentials.
'],
+            ['title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description_html' => '
Fresh pieces just landed.
'],
+            ['title' => 'Sale', 'handle' => 'sale', 'description_html' => '
Limited-time offers.
'],
+            ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '
Finishing touches for every order.
'],
+        ];
+
+        foreach ($collections as $collection) {
+            Collection::query()->updateOrCreate(
+                [
+                    'store_id' => $store->getKey(),
+                    'handle' => $collection['handle'],
+                ],
+                [
+                    'title' => $collection['title'],
+                    'description_html' => $collection['description_html'],
+                    'type' => 'manual',
+                    'status' => 'active',
+                ],
+            );
+        }
     }

@@ -4,4 +4,12 @@

-use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use App\Models\Collection as ProductCollection;
+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 Illuminate\Database\Seeder;
+use Illuminate\Support\Arr;

@@ -9,8 +17,191 @@
 {
+    public function run(): void
+    {
+        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
+        $collections = ProductCollection::query()
+            ->where('store_id', $store->getKey())
+            ->get()
+            ->keyBy('handle');
+
+        foreach ($this->products() as $index => $data) {
+            $product = Product::query()->updateOrCreate(
+                [
+                    'store_id' => $store->getKey(),
+                    'handle' => $data['handle'],
+                ],
+                [
+                    ...Arr::only($data, ['title', 'status', 'description_html', 'vendor', 'product_type', 'tags', 'published_at']),
+                    'store_id' => $store->getKey(),
+                ],
+            );
+
+            $this->createOptions($product, $data['options'] ?? []);
+            $this->createVariants($store, $product, $data['variants']);
+            $this->createMedia($product);
+
+            $collectionIds = collect($data['collections'] ?? [])
+                ->map(fn (string $handle): ?int => $collections->get($handle)?->getKey())
+                ->filter()
+                ->mapWithKeys(fn (int $collectionId): array => [$collectionId => ['position' => $index]])
+                ->all();
+
+            $product->collections()->syncWithoutDetaching($collectionIds);
+        }
+    }
+
+    /**
+     * @param  array<string, array<int, string>>  $options
+     */
+    private function createOptions(Product $product, array $options): void
+    {
+        foreach (array_values($options) as $optionPosition => $valuesByName) {
+            $option = ProductOption::query()->updateOrCreate(
+                [
+                    'product_id' => $product->getKey(),
+                    'position' => $optionPosition,
+                ],
+                [
+                    'name' => array_key_first($valuesByName),
+                ],
+            );
+
+            foreach (array_values($valuesByName[array_key_first($valuesByName)]) as $valuePosition => $value) {
+                ProductOptionValue::query()->updateOrCreate(
+                    [
+                        'product_option_id' => $option->getKey(),
+                        'position' => $valuePosition,
+                    ],
+                    ['value' => $value],
+                );
+            }
+        }
+    }
+
     /**
-     * Run the database seeds.
+     * @param  array<int, array<string, mixed>>  $variants
      */
-    public function run(): void
+    private function createVariants(Store $store, Product $product, array $variants): void
     {
-        //
+        foreach ($variants as $position => $variantData) {
+            $variant = ProductVariant::query()->updateOrCreate(
+                [
+                    'product_id' => $product->getKey(),
+                    'position' => $position,
+                ],
+                [
+                    'sku' => $variantData['sku'] ?? null,
+                    'barcode' => $variantData['barcode'] ?? null,
+                    'price_amount' => $variantData['price_amount'],
+                    'compare_at_amount' => $variantData['compare_at_amount'] ?? null,
+                    'currency' => $store->default_currency,
+                    'weight_g' => $variantData['weight_g'] ?? 250,
+                    'requires_shipping' => $variantData['requires_shipping'] ?? true,
+                    'is_default' => $position === 0,
+                    'status' => $variantData['status'] ?? 'active',
+                ],
+            );
+
+            InventoryItem::query()->updateOrCreate(
+                ['variant_id' => $variant->getKey()],
+                [
+                    'store_id' => $store->getKey(),
+                    'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 50,
+                    'quantity_reserved' => 0,
+                    'policy' => $variantData['policy'] ?? 'deny',
+                ],
+            );
+        }
+    }
+
+    private function createMedia(Product $product): void
+    {
+        ProductMedia::query()->updateOrCreate(
+            [
+                'product_id' => $product->getKey(),
+                'position' => 0,
+            ],
+            [
+                'type' => 'image',
+                'storage_key' => 'products/'.$product->handle.'.jpg',
+                'alt_text' => $product->title,
+                'width' => 1200,
+                'height' => 1200,
+                'mime_type' => 'image/jpeg',
+                'byte_size' => 180000,
+                'status' => 'ready',
+            ],
+        );
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    private function products(): array
+    {
+        $base = [
+            [
+                'title' => 'Classic Cotton T-Shirt',
+                'handle' => 'classic-cotton-t-shirt',
+                'product_type' => 'Shirts',
+                'tags' => ['new', 'popular'],
+                'collections' => ['t-shirts', 'new-arrivals'],
+                'options' => [
+                    ['Size' => ['S', 'M', 'L', 'XL']],
+                    ['Color' => ['Black', 'White', 'Navy']],
+                ],
+                'variants' => [
+                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                ],
+            ],
+            [
+                'title' => 'Premium Slim Fit Jeans',
+                'handle' => 'premium-slim-fit-jeans',
+                'product_type' => 'Pants',
+                'tags' => ['sale', 'popular'],
+                'collections' => ['new-arrivals', 'sale'],
+                'variants' => [
+                    ['sku' => 'JNS-SLM-32', 'price_amount' => 7999, 'compare_at_amount' => 9999, 'quantity_on_hand' => 35],
+                ],
+            ],
+        ];
+
+        for ($i = 3; $i <= 20; $i++) {
+            $base[] = [
+                'title' => match ($i) {
+                    15 => 'Draft Linen Overshirt',
+                    17 => 'Sold Out Canvas Tote',
+                    18 => 'Backorder Wool Scarf',
+                    default => "Acme Catalog Product {$i}",
+                },
+                'handle' => match ($i) {
+                    15 => 'draft-linen-overshirt',
+                    17 => 'sold-out-canvas-tote',
+                    18 => 'backorder-wool-scarf',
+                    default => "acme-catalog-product-{$i}",
+                },
+                'status' => $i === 15 ? 'draft' : 'active',
+                'published_at' => $i === 15 ? null : now(),
+                'product_type' => $i % 2 === 0 ? 'Accessories' : 'Shirts',
+                'tags' => $i % 2 === 0 ? ['new'] : ['trending'],
+                'collections' => $i % 2 === 0 ? ['accessories'] : ['new-arrivals'],
+                'variants' => [[
+                    'sku' => "ACME-{$i}",
+                    'price_amount' => $i === 17 ? 1999 : 2999 + ($i * 100),
+                    'quantity_on_hand' => in_array($i, [17, 18], true) ? 0 : 20 + $i,
+                    'policy' => $i === 18 ? 'continue' : 'deny',
+                ]],
+            ];
+        }
+
+        return array_map(function (array $product): array {
+            return [
+                'status' => 'active',
+                'description_html' => '
Built for the Acme Fashion demo catalog.
',
+                'vendor' => 'Acme',
+                'published_at' => now(),
+                ...$product,
+            ];
+        }, $base);
     }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/seeders/CollectionSeeder.php\nM /Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php\nM /Users/fabianwesner/Herd/shop/database/seeders/DatabaseSeeder.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php
@@
             InventoryItem::query()->updateOrCreate(
                 ['variant_id' => $variant->getKey()],
                 [
                     'store_id' => $store->getKey(),
@@
                     'policy' => $variantData['policy'] ?? 'deny',
                 ],
             );
+
+            if (isset($variantData['options'])) {
+                $this->syncVariantOptions($variant, $variantData['options']);
+            }
         }
     }
+
+    /**
+     * @param  array<string, string>  $selectedOptions
+     */
+    private function syncVariantOptions(ProductVariant $variant, array $selectedOptions): void
+    {
+        $valueIds = ProductOptionValue::query()
+            ->whereHas('option', function ($query) use ($variant, $selectedOptions): void {
+                $query
+                    ->where('product_id', $variant->product_id)
+                    ->whereIn('name', array_keys($selectedOptions));
+            })
+            ->get()
+            ->filter(fn (ProductOptionValue $value): bool => $selectedOptions[$value->option->name] === $value->value)
+            ->pluck('id')
+            ->all();
+
+        $variant->optionValues()->sync($valueIds);
+    }
@@
                 'variants' => [
-                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
-                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
-                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Black']],
+                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'White']],
+                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Navy']],
                 ],
             ],
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php

@@ -112,2 +112,6 @@
             );
+
+            if (isset($variantData['options'])) {
+                $this->syncVariantOptions($variant, $variantData['options']);
+            }
         }
@@ -115,2 +119,21 @@

+    /**
+     * @param  array<string, string>  $selectedOptions
+     */
+    private function syncVariantOptions(ProductVariant $variant, array $selectedOptions): void
+    {
+        $valueIds = ProductOptionValue::query()
+            ->whereHas('option', function ($query) use ($variant, $selectedOptions): void {
+                $query
+                    ->where('product_id', $variant->product_id)
+                    ->whereIn('name', array_keys($selectedOptions));
+            })
+            ->get()
+            ->filter(fn (ProductOptionValue $value): bool => $selectedOptions[$value->option->name] === $value->value)
+            ->pluck('id')
+            ->all();
+
+        $variant->optionValues()->sync($valueIds);
+    }
+
     private function createMedia(Product $product): void
@@ -152,5 +175,5 @@
                 'variants' => [
-                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
-                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
-                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50],
+                    ['sku' => 'TSH-BLK-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Black']],
+                    ['sku' => 'TSH-WHT-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'White']],
+                    ['sku' => 'TSH-NVY-M', 'price_amount' => 2499, 'quantity_on_hand' => 50, 'options' => ['Size' => 'M', 'Color' => 'Navy']],
                 ],


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/seeders/ProductSeeder.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php
@@
         return [
-            'store_id' => Store::factory(),
             'variant_id' => ProductVariant::factory(),
+            'store_id' => fn (array $attributes): mixed => ProductVariant::query()
+                ->with('product')
+                ->find($attributes['variant_id'])
+                ?->product
+                ?->store_id ?? Store::factory(),
             'quantity_on_hand' => fake()->numberBetween(0, 100),
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php

@@ -22,4 +22,8 @@
         return [
-            'store_id' => Store::factory(),
             'variant_id' => ProductVariant::factory(),
+            'store_id' => fn (array $attributes): mixed => ProductVariant::query()
+                ->with('product')
+                ->find($attributes['variant_id'])
+                ?->product
+                ?->store_id ?? Store::factory(),
             'quantity_on_hand' => fake()->numberBetween(0, 100),


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


The catalog seed data now creates the important fixture products and variant option mappings. I’m running migration/seed before writing more tests, because schema errors are cheaper to catch before service behavior tests.


php artisan migrate:fresh --seed --no-interaction


Output:
Chunk ID: 7a0546
Wall time: 0.7759 seconds
Process exited with code 0
Original token count: 826
Output:

  Dropping all tables ............................................ 5.11ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.77ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.89ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.43ms DONE
  0001_01_01_000002_create_jobs_table ............................ 0.94ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.43ms DONE
  2026_05_03_211409_create_organizations_table ................... 0.21ms DONE
  2026_05_03_211413_create_stores_table .......................... 4.08ms DONE
  2026_05_03_211416_create_store_domains_table ................... 0.45ms DONE
  2026_05_03_211419_create_store_settings_table .................. 0.19ms DONE
  2026_05_03_211425_create_store_users_table ..................... 0.34ms DONE
  2026_05_03_211556_create_customers_table ....................... 0.31ms DONE
  2026_05_03_212158_create_customer_password_reset_tokens_table .. 0.20ms DONE
  2026_05_03_213856_create_products_table ........................ 0.63ms DONE
  2026_05_03_213857_create_product_options_table ................. 0.35ms DONE
  2026_05_03_213858_create_product_option_values_table ........... 0.33ms DONE
  2026_05_03_213859_create_product_variants_table ................ 0.61ms DONE
  2026_05_03_213900_create_variant_option_values_table ........... 0.31ms DONE
  2026_05_03_213901_create_inventory_items_table ................. 0.32ms DONE
  2026_05_03_213902_create_collections_table ..................... 0.40ms DONE
  2026_05_03_213903_create_collection_products_table ............. 0.33ms DONE
  2026_05_03_213904_create_product_media_table ................... 0.45ms DONE

   INFO  Seeding database.

  Database\Seeders\OrganizationSeeder ................................ RUNNING
  Database\Seeders\OrganizationSeeder .............................. 5 ms DONE

  Database\Seeders\StoreSeeder ....................................... RUNNING
  Database\Seeders\StoreSeeder ..................................... 1 ms DONE

  Database\Seeders\StoreDomainSeeder ................................. RUNNING
  Database\Seeders\StoreDomainSeeder ............................... 1 ms DONE

  Database\Seeders\UserSeeder ........................................ RUNNING
  Database\Seeders\UserSeeder .................................... 268 ms DONE

  Database\Seeders\StoreUserSeeder ................................... RUNNING
  Database\Seeders\StoreUserSeeder ................................. 0 ms DONE

  Database\Seeders\StoreSettingsSeeder ............................... RUNNING
  Database\Seeders\StoreSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\CollectionSeeder .................................. RUNNING
  Database\Seeders\CollectionSeeder ................................ 2 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder .................................. 38 ms DONE

  Database\Seeders\CustomerSeeder .................................... RUNNING
  Database\Seeders\CustomerSeeder ................................ 262 ms DONE


Output:
Chunk ID: f6a6d2
Wall time: 0.1116 seconds
Process exited with code 0
Original token count: 48
Output:
array:5 [
  "products" => 20
  "collections" => 4
  "variants" => 22
  "inventory" => 22
  "variant_option_values" => 6
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:1


array:5 [
  "products" => 20
  "collections" => 4
  "variants" => 22
  "inventory" => 22
  "variant_option_values" => 6
] // vendor/psy/psysh/src/ExecutionClosure.php(41) : eval()'d code:1


php artisan make:test --pest Catalog/CatalogFoundationTest --no-interaction && php artisan make:test --pest Catalog/ProductServiceTest --no-interaction && php artisan make:test --pest Catalog/InventoryServiceTest --no-interaction && php artisan make:test --pest Catalog/VariantMatrixServiceTest --no-interaction


Output:
Chunk ID: 8ad976
Wall time: 0.4433 seconds
Process exited with code 0
Original token count: 90
Output:

   INFO  Test [tests/Feature/Catalog/CatalogFoundationTest.php] created successfully.

   INFO  Test [tests/Feature/Catalog/ProductServiceTest.php] created successfully.

   INFO  Test [tests/Feature/Catalog/InventoryServiceTest.php] created successfully.

   INFO  Test [tests/Feature/Catalog/VariantMatrixServiceTest.php] created successfully.


php artisan make:test --pest Catalog/CatalogFoundationTest --no-interaction && php artisan make:test --pest Catalog/ProductServiceTest --no-interaction && php artisan make:test --pest Catalog/InventoryServiceTest --no-interaction && php artisan make:test --pest Catalog/VariantMatrixServiceTest --no-interaction

   INFO  Test [tests/Feature/Catalog/CatalogFoundationTest.php] created successfully.

   INFO  Test [tests/Feature/Catalog/ProductServiceTest.php] created successfully.

   INFO  Test [tests/Feature/Catalog/InventoryServiceTest.php] created successfully.

   INFO  Test [tests/Feature/Catalog/VariantMatrixServiceTest.php] created successfully.


I’m adding targeted Pest coverage for the catalog slice: schema and seed fixtures, tenant scoping, product status/slug/SKU behavior, inventory transactions, and variant matrix rebuilds. These are feature-level because they exercise Eloquent, migrations, factories, and services together.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/CatalogFoundationTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Collection;
+use App\Models\Product;
+use App\Models\Store;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Schema;

-    $response->assertStatus(200);
+uses(RefreshDatabase::class);
+
+test('catalog tables and seeded fixtures exist', function () {
+    $this->seed(DatabaseSeeder::class);
+
+    expect(Schema::hasColumns('products', ['store_id', 'title', 'handle', 'status', 'tags']))->toBeTrue()
+        ->and(Schema::hasColumns('product_variants', ['product_id', 'sku', 'price_amount', 'requires_shipping']))->toBeTrue()
+        ->and(Schema::hasColumns('inventory_items', ['store_id', 'variant_id', 'quantity_on_hand', 'quantity_reserved', 'policy']))->toBeTrue()
+        ->and(Schema::hasColumns('collections', ['store_id', 'title', 'handle', 'type', 'status']))->toBeTrue()
+        ->and(Product::withoutGlobalScopes()->count())->toBe(20)
+        ->and(Collection::withoutGlobalScopes()->where('handle', 't-shirts')->exists())->toBeTrue()
+        ->and(Product::withoutGlobalScopes()->where('handle', 'classic-cotton-t-shirt')->first()?->variants()->count())->toBe(3)
+        ->and(Product::withoutGlobalScopes()->where('handle', 'sold-out-canvas-tote')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('deny')
+        ->and(Product::withoutGlobalScopes()->where('handle', 'backorder-wool-scarf')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('continue');
+});
+
+test('catalog models are scoped to the resolved store', function () {
+    $firstStore = Store::factory()->create();
+    $secondStore = Store::factory()->create();
+
+    Product::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-store-product']);
+    Product::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-store-product']);
+
+    app()->instance('current_store', $firstStore);
+
+    expect(Product::query()->pluck('handle')->all())->toBe(['first-store-product']);
 });
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/ProductServiceTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\ProductStatus;
+use App\Events\ProductStatusChanged;
+use App\Exceptions\InvalidProductTransitionException;
+use App\Models\Product;
+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;

-    $response->assertStatus(200);
+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 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);
 });
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Exceptions\InsufficientInventoryException;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\InventoryService;
+use Illuminate\Foundation\Testing\RefreshDatabase;

-    $response->assertStatus(200);
+uses(RefreshDatabase::class);
+
+test('inventory reserve release commit and restock update quantities transactionally', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
+    $item = $variant->inventoryItem()->firstOrFail();
+    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();
+
+    $service = app(InventoryService::class);
+
+    $service->reserve($item, 4);
+    expect($item->refresh()->quantity_reserved)->toBe(4);
+
+    $service->release($item, 1);
+    expect($item->refresh()->quantity_reserved)->toBe(3);
+
+    $service->commit($item, 2);
+    expect($item->refresh()->quantity_on_hand)->toBe(8)
+        ->and($item->quantity_reserved)->toBe(1);
+
+    $service->restock($item, 5);
+    expect($item->refresh()->quantity_on_hand)->toBe(13);
+});
+
+test('deny policy blocks reservations above available stock while continue allows it', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $denyItem = InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'quantity_on_hand' => 1,
+        'quantity_reserved' => 0,
+        'policy' => 'deny',
+    ]);
+
+    expect(fn () => app(InventoryService::class)->reserve($denyItem, 2))
+        ->toThrow(InsufficientInventoryException::class);
+
+    $continueItem = InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'quantity_on_hand' => 0,
+        'quantity_reserved' => 0,
+        'policy' => 'continue',
+    ]);
+
+    app(InventoryService::class)->reserve($continueItem, 2);
+
+    expect($continueItem->refresh()->quantity_reserved)->toBe(2);
 });
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Product;
+use App\Models\ProductOption;
+use App\Models\ProductOptionValue;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\VariantMatrixService;
+use Illuminate\Foundation\Testing\RefreshDatabase;

-    $response->assertStatus(200);
+uses(RefreshDatabase::class);
+
+test('variant matrix rebuild creates missing cartesian combinations and inventory', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()->create(['store_id' => $store->getKey()]);
+
+    $size = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Size', 'position' => 0]);
+    $small = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'S', 'position' => 0]);
+    $medium = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'M', 'position' => 1]);
+
+    $color = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Color', 'position' => 1]);
+    $black = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'Black', 'position' => 0]);
+    $white = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'White', 'position' => 1]);
+
+    $variant = ProductVariant::factory()->default()->create([
+        'product_id' => $product->getKey(),
+        'price_amount' => 2500,
+    ]);
+    $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);
+
+    app(VariantMatrixService::class)->rebuildMatrix($product);
+
+    expect($product->variants()->count())->toBe(4)
+        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($medium->getKey()))->count())->toBe(2)
+        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($white->getKey()))->count())->toBe(2)
+        ->and($product->variants()->whereHas('inventoryItem')->count())->toBe(4);
+});
+
+test('variant matrix rebuild creates a default variant when there are no options', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()->create(['store_id' => $store->getKey()]);
+    $product->variants()->delete();
+
+    app(VariantMatrixService::class)->rebuildMatrix($product);
+
+    expect($product->variants()->count())->toBe(1)
+        ->and($product->variants()->first()->is_default)->toBeTrue();
 });
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/CatalogFoundationTest.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/ProductServiceTest.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php

@@ -2,6 +2,61 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Exceptions\InsufficientInventoryException;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\InventoryService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+test('inventory reserve release commit and restock update quantities transactionally', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);

-    $response->assertStatus(200);
+    $product = Product::factory()->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
+    $item = $variant->inventoryItem()->firstOrFail();
+    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();
+
+    $service = app(InventoryService::class);
+
+    $service->reserve($item, 4);
+    expect($item->refresh()->quantity_reserved)->toBe(4);
+
+    $service->release($item, 1);
+    expect($item->refresh()->quantity_reserved)->toBe(3);
+
+    $service->commit($item, 2);
+    expect($item->refresh()->quantity_on_hand)->toBe(8)
+        ->and($item->quantity_reserved)->toBe(1);
+
+    $service->restock($item, 5);
+    expect($item->refresh()->quantity_on_hand)->toBe(13);
+});
+
+test('deny policy blocks reservations above available stock while continue allows it', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $denyItem = InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'quantity_on_hand' => 1,
+        'quantity_reserved' => 0,
+        'policy' => 'deny',
+    ]);
+
+    expect(fn () => app(InventoryService::class)->reserve($denyItem, 2))
+        ->toThrow(InsufficientInventoryException::class);
+
+    $continueItem = InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'quantity_on_hand' => 0,
+        'quantity_reserved' => 0,
+        'policy' => 'continue',
+    ]);
+
+    app(InventoryService::class)->reserve($continueItem, 2);
+
+    expect($continueItem->refresh()->quantity_reserved)->toBe(2);
 });

@@ -2,6 +2,89 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\ProductStatus;
+use App\Events\ProductStatusChanged;
+use App\Exceptions\InvalidProductTransitionException;
+use App\Models\Product;
+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;

-    $response->assertStatus(200);
+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 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);
 });

@@ -2,6 +2,51 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Product;
+use App\Models\ProductOption;
+use App\Models\ProductOptionValue;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\VariantMatrixService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+test('variant matrix rebuild creates missing cartesian combinations and inventory', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);

-    $response->assertStatus(200);
+    $product = Product::factory()->create(['store_id' => $store->getKey()]);
+
+    $size = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Size', 'position' => 0]);
+    $small = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'S', 'position' => 0]);
+    $medium = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'M', 'position' => 1]);
+
+    $color = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Color', 'position' => 1]);
+    $black = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'Black', 'position' => 0]);
+    $white = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'White', 'position' => 1]);
+
+    $variant = ProductVariant::factory()->default()->create([
+        'product_id' => $product->getKey(),
+        'price_amount' => 2500,
+    ]);
+    $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);
+
+    app(VariantMatrixService::class)->rebuildMatrix($product);
+
+    expect($product->variants()->count())->toBe(4)
+        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($medium->getKey()))->count())->toBe(2)
+        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($white->getKey()))->count())->toBe(2)
+        ->and($product->variants()->whereHas('inventoryItem')->count())->toBe(4);
+});
+
+test('variant matrix rebuild creates a default variant when there are no options', function () {
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()->create(['store_id' => $store->getKey()]);
+    $product->variants()->delete();
+
+    app(VariantMatrixService::class)->rebuildMatrix($product);
+
+    expect($product->variants()->count())->toBe(1)
+        ->and($product->variants()->first()->is_default)->toBeTrue();
 });

@@ -2,6 +2,35 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Models\Collection;
+use App\Models\Product;
+use App\Models\Store;
+use Database\Seeders\DatabaseSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Schema;
+
+uses(RefreshDatabase::class);
+
+test('catalog tables and seeded fixtures exist', function () {
+    $this->seed(DatabaseSeeder::class);

-    $response->assertStatus(200);
+    expect(Schema::hasColumns('products', ['store_id', 'title', 'handle', 'status', 'tags']))->toBeTrue()
+        ->and(Schema::hasColumns('product_variants', ['product_id', 'sku', 'price_amount', 'requires_shipping']))->toBeTrue()
+        ->and(Schema::hasColumns('inventory_items', ['store_id', 'variant_id', 'quantity_on_hand', 'quantity_reserved', 'policy']))->toBeTrue()
+        ->and(Schema::hasColumns('collections', ['store_id', 'title', 'handle', 'type', 'status']))->toBeTrue()
+        ->and(Product::withoutGlobalScopes()->count())->toBe(20)
+        ->and(Collection::withoutGlobalScopes()->where('handle', 't-shirts')->exists())->toBeTrue()
+        ->and(Product::withoutGlobalScopes()->where('handle', 'classic-cotton-t-shirt')->first()?->variants()->count())->toBe(3)
+        ->and(Product::withoutGlobalScopes()->where('handle', 'sold-out-canvas-tote')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('deny')
+        ->and(Product::withoutGlobalScopes()->where('handle', 'backorder-wool-scarf')->first()?->variants()->first()?->inventoryItem?->policy?->value)->toBe('continue');
+});
+
+test('catalog models are scoped to the resolved store', function () {
+    $firstStore = Store::factory()->create();
+    $secondStore = Store::factory()->create();
+
+    Product::factory()->create(['store_id' => $firstStore->getKey(), 'handle' => 'first-store-product']);
+    Product::factory()->create(['store_id' => $secondStore->getKey(), 'handle' => 'second-store-product']);
+
+    app()->instance('current_store', $firstStore);
+
+    expect(Product::query()->pluck('handle')->all())->toBe(['first-store-product']);
 });


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/CatalogFoundationTest.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/ProductServiceTest.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/ProductVariantFactory.php
@@
-use App\Models\InventoryItem;
 use App\Models\Product;
@@
-    public function configure(): static
-    {
-        return $this->afterCreating(function ($variant): void {
-            if ($variant->inventoryItem()->exists()) {
-                return;
-            }
-
-            InventoryItem::factory()->create([
-                'store_id' => $variant->product->store_id,
-                'variant_id' => $variant->getKey(),
-            ]);
-        });
-    }
-
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php
@@
-use App\Models\ProductVariant;
+use App\Models\InventoryItem;
+use App\Models\ProductVariant;
@@
         return $this->afterCreating(function ($product) use ($price): void {
-            ProductVariant::factory()
+            $variant = ProductVariant::factory()
                 ->default()
                 ->for($product)
                 ->create([
                     'price_amount' => $price,
                     'currency' => $product->store->default_currency,
                 ]);
+
+            InventoryItem::factory()->create([
+                'store_id' => $product->store_id,
+                'variant_id' => $variant->getKey(),
+            ]);
         });
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php
@@
     $product = Product::factory()->create(['store_id' => $store->getKey()]);
     $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
-    $item = $variant->inventoryItem()->firstOrFail();
-    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();
+    $item = InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'variant_id' => $variant->getKey(),
+        'quantity_on_hand' => 10,
+        'quantity_reserved' => 0,
+        'policy' => 'deny',
+    ]);
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php
@@
     $variant = ProductVariant::factory()->default()->create([
         'product_id' => $product->getKey(),
         'price_amount' => 2500,
     ]);
+    \App\Models\InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'variant_id' => $variant->getKey(),
+    ]);
     $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);
*** End Patch


Output:
apply_patch verification failed: Failed to find expected lines in /Users/fabianwesner/Herd/shop/database/factories/ProductVariantFactory.php:
use App\Models\InventoryItem;
use App\Models\Product;


sed -n '1,180p' database/factories/ProductVariantFactory.php


sed -n '1,160p' database/factories/ProductFactory.php


sed -n '1,120p' tests/Feature/Catalog/InventoryServiceTest.php && sed -n '1,120p' tests/Feature/Catalog/VariantMatrixServiceTest.php


sed -n '1,160p' database/factories/ProductFactory.php

<?php

namespace Database\Factories;

use App\Enums\ProductStatus;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
 */
class ProductFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(3, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'status' => ProductStatus::Active,
            'description_html' => '<p>'.fake()->paragraph().'</p>',
            'vendor' => fake()->company(),
            'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']),
            'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)),
            'published_at' => now(),
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Draft,
            'published_at' => null,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Archived,
        ]);
    }

    public function withVariants(int $count): static
    {
        return $this->afterCreating(function ($product) use ($count): void {
            ProductVariant::factory()
                ->count($count)
                ->for($product)
                ->create();
        });
    }

    public function withDefaultVariant(int $price = 2499): static
    {
        return $this->afterCreating(function ($product) use ($price): void {
            ProductVariant::factory()
                ->default()
                ->for($product)
                ->create([
                    'price_amount' => $price,
                    'currency' => $product->store->default_currency,
                ]);
        });
    }
}


sed -n '1,180p' database/factories/ProductVariantFactory.php

<?php

namespace Database\Factories;

use App\Enums\VariantStatus;
use App\Models\Product;
use App\Models\InventoryItem;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductVariant>
 */
class ProductVariantFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'sku' => 'SKU-'.fake()->unique()->numerify('####').'-'.fake()->lexify('???'),
            'barcode' => fake()->ean13(),
            'price_amount' => fake()->numberBetween(999, 19999),
            'compare_at_amount' => null,
            'currency' => 'EUR',
            'weight_g' => fake()->numberBetween(100, 5000),
            'requires_shipping' => true,
            'is_default' => false,
            'position' => 0,
            'status' => VariantStatus::Active,
        ];
    }

    public function configure(): static
    {
        return $this->afterCreating(function ($variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            InventoryItem::factory()->create([
                'store_id' => $variant->product->store_id,
                'variant_id' => $variant->getKey(),
            ]);
        });
    }

    public function onSale(): static
    {
        return $this->state(fn (array $attributes): array => [
            'compare_at_amount' => fake()->numberBetween(20000, 39999),
            'price_amount' => fake()->numberBetween(9999, 19999),
        ]);
    }

    public function digital(): static
    {
        return $this->state(fn (array $attributes): array => [
            'requires_shipping' => false,
            'weight_g' => 0,
        ]);
    }

    public function default(): static
    {
        return $this->state(fn (array $attributes): array => [
            'is_default' => true,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => VariantStatus::Archived,
        ]);
    }
}


sed -n '1,120p' tests/Feature/Catalog/InventoryServiceTest.php && sed -n '1,120p' tests/Feature/Catalog/VariantMatrixServiceTest.php

VariantMatrixServiceTest.php

<?php

use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\InventoryService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('inventory reserve release commit and restock update quantities transactionally', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
    $item = $variant->inventoryItem()->firstOrFail();
    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();

    $service = app(InventoryService::class);

    $service->reserve($item, 4);
    expect($item->refresh()->quantity_reserved)->toBe(4);

    $service->release($item, 1);
    expect($item->refresh()->quantity_reserved)->toBe(3);

    $service->commit($item, 2);
    expect($item->refresh()->quantity_on_hand)->toBe(8)
        ->and($item->quantity_reserved)->toBe(1);

    $service->restock($item, 5);
    expect($item->refresh()->quantity_on_hand)->toBe(13);
});

test('deny policy blocks reservations above available stock while continue allows it', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $denyItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 1,
        'quantity_reserved' => 0,
        'policy' => 'deny',
    ]);

    expect(fn () => app(InventoryService::class)->reserve($denyItem, 2))
        ->toThrow(InsufficientInventoryException::class);

    $continueItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 0,
        'quantity_reserved' => 0,
        'policy' => 'continue',
    ]);

    app(InventoryService::class)->reserve($continueItem, 2);

    expect($continueItem->refresh()->quantity_reserved)->toBe(2);
});
<?php

use App\Models\Product;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\VariantMatrixService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('variant matrix rebuild creates missing cartesian combinations and inventory', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

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

    $size = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Size', 'position' => 0]);
    $small = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'S', 'position' => 0]);
    $medium = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'M', 'position' => 1]);

    $color = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Color', 'position' => 1]);
    $black = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'Black', 'position' => 0]);
    $white = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'White', 'position' => 1]);

    $variant = ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 2500,
    ]);
    $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(4)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($medium->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($white->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('inventoryItem')->count())->toBe(4);
});

test('variant matrix rebuild creates a default variant when there are no options', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $product->variants()->delete();

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(1)
        ->and($product->variants()->first()->is_default)->toBeTrue();
});


Output:
Chunk ID: b7eaee
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 556
Output:
<?php

namespace Database\Factories;

use App\Enums\VariantStatus;
use App\Models\Product;
use App\Models\InventoryItem;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductVariant>
 */
class ProductVariantFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'sku' => 'SKU-'.fake()->unique()->numerify('####').'-'.fake()->lexify('???'),
            'barcode' => fake()->ean13(),
            'price_amount' => fake()->numberBetween(999, 19999),
            'compare_at_amount' => null,
            'currency' => 'EUR',
            'weight_g' => fake()->numberBetween(100, 5000),
            'requires_shipping' => true,
            'is_default' => false,
            'position' => 0,
            'status' => VariantStatus::Active,
        ];
    }

    public function configure(): static
    {
        return $this->afterCreating(function ($variant): void {
            if ($variant->inventoryItem()->exists()) {
                return;
            }

            InventoryItem::factory()->create([
                'store_id' => $variant->product->store_id,
                'variant_id' => $variant->getKey(),
            ]);
        });
    }

    public function onSale(): static
    {
        return $this->state(fn (array $attributes): array => [
            'compare_at_amount' => fake()->numberBetween(20000, 39999),
            'price_amount' => fake()->numberBetween(9999, 19999),
        ]);
    }

    public function digital(): static
    {
        return $this->state(fn (array $attributes): array => [
            'requires_shipping' => false,
            'weight_g' => 0,
        ]);
    }

    public function default(): static
    {
        return $this->state(fn (array $attributes): array => [
            'is_default' => true,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => VariantStatus::Archived,
        ]);
    }
}


Output:
Chunk ID: faf71c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 556
Output:
<?php

namespace Database\Factories;

use App\Enums\ProductStatus;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
 */
class ProductFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(3, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'status' => ProductStatus::Active,
            'description_html' => '<p>'.fake()->paragraph().'</p>',
            'vendor' => fake()->company(),
            'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']),
            'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)),
            'published_at' => now(),
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Draft,
            'published_at' => null,
        ]);
    }

    public function archived(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Archived,
        ]);
    }

    public function withVariants(int $count): static
    {
        return $this->afterCreating(function ($product) use ($count): void {
            ProductVariant::factory()
                ->count($count)
                ->for($product)
                ->create();
        });
    }

    public function withDefaultVariant(int $price = 2499): static
    {
        return $this->afterCreating(function ($product) use ($price): void {
            ProductVariant::factory()
                ->default()
                ->for($product)
                ->create([
                    'price_amount' => $price,
                    'currency' => $product->store->default_currency,
                ]);
        });
    }
}


Output:
Chunk ID: 04f012
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1146
Output:
<?php

use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\InventoryService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('inventory reserve release commit and restock update quantities transactionally', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
    $item = $variant->inventoryItem()->firstOrFail();
    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();

    $service = app(InventoryService::class);

    $service->reserve($item, 4);
    expect($item->refresh()->quantity_reserved)->toBe(4);

    $service->release($item, 1);
    expect($item->refresh()->quantity_reserved)->toBe(3);

    $service->commit($item, 2);
    expect($item->refresh()->quantity_on_hand)->toBe(8)
        ->and($item->quantity_reserved)->toBe(1);

    $service->restock($item, 5);
    expect($item->refresh()->quantity_on_hand)->toBe(13);
});

test('deny policy blocks reservations above available stock while continue allows it', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $denyItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 1,
        'quantity_reserved' => 0,
        'policy' => 'deny',
    ]);

    expect(fn () => app(InventoryService::class)->reserve($denyItem, 2))
        ->toThrow(InsufficientInventoryException::class);

    $continueItem = InventoryItem::factory()->create([
        'store_id' => $store->getKey(),
        'quantity_on_hand' => 0,
        'quantity_reserved' => 0,
        'policy' => 'continue',
    ]);

    app(InventoryService::class)->reserve($continueItem, 2);

    expect($continueItem->refresh()->quantity_reserved)->toBe(2);
});
<?php

use App\Models\Product;
use App\Models\ProductOption;
use App\Models\ProductOptionValue;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\VariantMatrixService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('variant matrix rebuild creates missing cartesian combinations and inventory', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

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

    $size = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Size', 'position' => 0]);
    $small = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'S', 'position' => 0]);
    $medium = ProductOptionValue::factory()->create(['product_option_id' => $size->getKey(), 'value' => 'M', 'position' => 1]);

    $color = ProductOption::factory()->create(['product_id' => $product->getKey(), 'name' => 'Color', 'position' => 1]);
    $black = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'Black', 'position' => 0]);
    $white = ProductOptionValue::factory()->create(['product_option_id' => $color->getKey(), 'value' => 'White', 'position' => 1]);

    $variant = ProductVariant::factory()->default()->create([
        'product_id' => $product->getKey(),
        'price_amount' => 2500,
    ]);
    $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(4)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($medium->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('optionValues', fn ($query) => $query->whereKey($white->getKey()))->count())->toBe(2)
        ->and($product->variants()->whereHas('inventoryItem')->count())->toBe(4);
});

test('variant matrix rebuild creates a default variant when there are no options', function () {
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    $product = Product::factory()->create(['store_id' => $store->getKey()]);
    $product->variants()->delete();

    app(VariantMatrixService::class)->rebuildMatrix($product);

    expect($product->variants()->count())->toBe(1)
        ->and($product->variants()->first()->is_default)->toBeTrue();
});


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/ProductVariantFactory.php
@@
 use App\Enums\VariantStatus;
 use App\Models\Product;
-use App\Models\InventoryItem;
 use Illuminate\Database\Eloquent\Factories\Factory;
@@
-    public function configure(): static
-    {
-        return $this->afterCreating(function ($variant): void {
-            if ($variant->inventoryItem()->exists()) {
-                return;
-            }
-
-            InventoryItem::factory()->create([
-                'store_id' => $variant->product->store_id,
-                'variant_id' => $variant->getKey(),
-            ]);
-        });
-    }
-
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php
@@
 use App\Enums\ProductStatus;
+use App\Models\InventoryItem;
 use App\Models\ProductVariant;
@@
-            ProductVariant::factory()
+            $variant = ProductVariant::factory()
                 ->default()
                 ->for($product)
                 ->create([
                     'price_amount' => $price,
                     'currency' => $product->store->default_currency,
                 ]);
+
+            InventoryItem::factory()->create([
+                'store_id' => $product->store_id,
+                'variant_id' => $variant->getKey(),
+            ]);
         });
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php
@@
     $product = Product::factory()->create(['store_id' => $store->getKey()]);
     $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
-    $item = $variant->inventoryItem()->firstOrFail();
-    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();
+    $item = InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'variant_id' => $variant->getKey(),
+        'quantity_on_hand' => 10,
+        'quantity_reserved' => 0,
+        'policy' => 'deny',
+    ]);
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php
@@
 use App\Models\Product;
+use App\Models\InventoryItem;
@@
     $variant = ProductVariant::factory()->default()->create([
         'product_id' => $product->getKey(),
         'price_amount' => 2500,
     ]);
+    InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'variant_id' => $variant->getKey(),
+    ]);
     $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/factories/ProductVariantFactory.php
M /Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php

@@ -5,2 +5,3 @@
 use App\Enums\ProductStatus;
+use App\Models\InventoryItem;
 use App\Models\ProductVariant;
@@ -65,3 +66,3 @@
         return $this->afterCreating(function ($product) use ($price): void {
-            ProductVariant::factory()
+            $variant = ProductVariant::factory()
                 ->default()
@@ -72,2 +73,7 @@
                 ]);
+
+            InventoryItem::factory()->create([
+                'store_id' => $product->store_id,
+                'variant_id' => $variant->getKey(),
+            ]);
         });

@@ -6,3 +6,2 @@
 use App\Models\Product;
-use App\Models\InventoryItem;
 use Illuminate\Database\Eloquent\Factories\Factory;
@@ -34,16 +33,2 @@
         ];
-    }
-
-    public function configure(): static
-    {
-        return $this->afterCreating(function ($variant): void {
-            if ($variant->inventoryItem()->exists()) {
-                return;
-            }
-
-            InventoryItem::factory()->create([
-                'store_id' => $variant->product->store_id,
-                'variant_id' => $variant->getKey(),
-            ]);
-        });
     }

@@ -3,2 +3,3 @@
 use App\Models\Product;
+use App\Models\InventoryItem;
 use App\Models\ProductOption;
@@ -30,2 +31,6 @@
     ]);
+    InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'variant_id' => $variant->getKey(),
+    ]);
     $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);

@@ -18,4 +18,9 @@
     $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
-    $item = $variant->inventoryItem()->firstOrFail();
-    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();
+    $item = InventoryItem::factory()->create([
+        'store_id' => $store->getKey(),
+        'variant_id' => $variant->getKey(),
+        'quantity_on_hand' => 10,
+        'quantity_reserved' => 0,
+        'policy' => 'deny',
+    ]);


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/factories/ProductVariantFactory.php\nM /Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


One factory interaction would have created duplicate inventory rows when `InventoryItemFactory` built its own variant, so I separated variant factories from inventory creation and made inventory explicit in the hooks/tests that need it. I’m running the catalog tests now.


php artisan test --compact tests/Feature/Catalog


Output:
Chunk ID: 876517
Wall time: 0.6889 seconds
Process exited with code 0
Original token count: 18
Output:

  ..........

  Tests:    10 passed (33 assertions)
  Duration: 0.43s


php artisan test --compact tests/Feature/Catalog

  ..........

  Tests:    10 passed (33 assertions)
  Duration: 0.43s


Output:
Chunk ID: dbb105
Wall time: 2.6446 seconds
Process exited with code 0
Original token count: 30
Output:

  .......................................................

  Tests:    55 passed (154 assertions)
  Duration: 2.53s


Catalog tests pass and the whole current suite is green at 55 tests / 154 assertions. I’m formatting the new PHP files, then I’ll run migrate/seed and inspect the schema again.


Output:
Chunk ID: d2cdf3
Wall time: 0.7682 seconds
Process exited with code 0
Original token count: 138
Output:
{"result":"fixed","files":[{"path":"database\/seeders\/ProductOptionSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/ProductVariantSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/InventoryItemSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/ProductMediaSeeder.php","fixers":["no_unused_imports"]},{"path":"database\/seeders\/ProductOptionValueSeeder.php","fixers":["no_unused_imports"]},{"path":"tests\/Feature\/Catalog\/VariantMatrixServiceTest.php","fixers":["ordered_imports"]}]}


/bin/zsh
-lc
vendor/bin/pint --dirty --format agent


Output:
Chunk ID: 38f6bc
Wall time: 2.5128 seconds
Process exited with code 0
Original token count: 30
Output:

  .......................................................

  Tests:    55 passed (154 assertions)
  Duration: 2.41s


Output:
Chunk ID: cf1184
Wall time: 0.6546 seconds
Process exited with code 0
Original token count: 826
Output:

  Dropping all tables ............................................ 3.00ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.46ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.69ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.45ms DONE
  0001_01_01_000002_create_jobs_table ............................ 0.69ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.45ms DONE
  2026_05_03_211409_create_organizations_table ................... 0.22ms DONE
  2026_05_03_211413_create_stores_table .......................... 2.33ms DONE
  2026_05_03_211416_create_store_domains_table ................... 0.40ms DONE
  2026_05_03_211419_create_store_settings_table .................. 0.17ms DONE
  2026_05_03_211425_create_store_users_table ..................... 0.33ms DONE
  2026_05_03_211556_create_customers_table ....................... 0.32ms DONE
  2026_05_03_212158_create_customer_password_reset_tokens_table .. 0.19ms DONE
  2026_05_03_213856_create_products_table ........................ 0.56ms DONE
  2026_05_03_213857_create_product_options_table ................. 0.29ms DONE
  2026_05_03_213858_create_product_option_values_table ........... 0.30ms DONE
  2026_05_03_213859_create_product_variants_table ................ 0.57ms DONE
  2026_05_03_213900_create_variant_option_values_table ........... 0.24ms DONE
  2026_05_03_213901_create_inventory_items_table ................. 0.30ms DONE
  2026_05_03_213902_create_collections_table ..................... 0.39ms DONE
  2026_05_03_213903_create_collection_products_table ............. 0.31ms DONE
  2026_05_03_213904_create_product_media_table ................... 0.44ms DONE

   INFO  Seeding database.

  Database\Seeders\OrganizationSeeder ................................ RUNNING
  Database\Seeders\OrganizationSeeder .............................. 4 ms DONE

  Database\Seeders\StoreSeeder ....................................... RUNNING
  Database\Seeders\StoreSeeder ..................................... 1 ms DONE

  Database\Seeders\StoreDomainSeeder ................................. RUNNING
  Database\Seeders\StoreDomainSeeder ............................... 1 ms DONE

  Database\Seeders\UserSeeder ........................................ RUNNING
  Database\Seeders\UserSeeder .................................... 269 ms DONE

  Database\Seeders\StoreUserSeeder ................................... RUNNING
  Database\Seeders\StoreUserSeeder ................................. 0 ms DONE

  Database\Seeders\StoreSettingsSeeder ............................... RUNNING
  Database\Seeders\StoreSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\CollectionSeeder .................................. RUNNING
  Database\Seeders\CollectionSeeder ................................ 2 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder .................................. 36 ms DONE

  Database\Seeders\CustomerSeeder .................................... RUNNING
  Database\Seeders\CustomerSeeder ................................ 265 ms DONE


varchar

varchar


Phase 2 catalog checks are green after formatting and real migration/seed. I’m asking independent reviewers to look for missing catalog criteria and code risks before I commit this slice.


Read-only QA analyst for /Users/fabianwesner/Herd/shop. Compare the current Phase 2 catalog implementation against specs/01-DATABASE-SCHEMA.md Epic 2, specs/05-BUSINESS-LOGIC.md sections 2-3 and 15, specs/07 seed requirements for catalog, and specs/progress.md. Look for missing requirements, partial implementations, or acceptance criteria without evidence. Do not edit files. Return critical/high/medium findings with file references and suggested verification.


Prompt: Read-only QA analyst for /Users/fabianwesner/Herd/shop. Compare the current Phase 2 catalog implementation against specs/01-DATABASE-SCHEMA.md Epic 2, specs/05-BUSINESS-LOGIC.md sections 2-3 and 15, specs/07 seed requirements for catalog, and specs/progress.md. Look for missing requirements, partial implementations, or acceptance criteria without evidence. Do not edit files. Return critical/high/medium findings with file references and suggested verification.


Read-only Laravel code reviewer for /Users/fabianwesner/Herd/shop. Review the current uncommitted Phase 2 catalog code for Laravel correctness, tenant isolation, migrations/factories/seeders, service logic, tests, and security/validation risks. Focus on bugs and high-risk issues, not praise. Do not edit files. Return findings ordered by severity with concrete file references.


Prompt: Read-only Laravel code reviewer for /Users/fabianwesner/Herd/shop. Review the current uncommitted Phase 2 catalog code for Laravel correctness, tenant isolation, migrations/factories/seeders, service logic, tests, and security/validation risks. Focus on bugs and high-risk issues, not praise. Do not edit files. Return findings ordered by severity with concrete file references.


While the reviewers run, I found one spec-level gap myself: inventory should be created automatically for every variant, not just when services or seeders remember to do it. I’m moving that responsibility onto the `ProductVariant` model and changing service/factory code to update the auto-created inventory row instead of duplicating it.


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php
@@
 class ProductVariant extends Model
 {
@@
     protected $attributes = [
@@
         'status' => 'active',
     ];
+
+    protected static function booted(): void
+    {
+        static::created(function (ProductVariant $variant): void {
+            if ($variant->inventoryItem()->exists()) {
+                return;
+            }
+
+            $storeId = $variant->product()->value('store_id');
+
+            if (! $storeId) {
+                return;
+            }
+
+            InventoryItem::withoutGlobalScopes()->create([
+                'store_id' => $storeId,
+                'variant_id' => $variant->getKey(),
+                'quantity_on_hand' => 0,
+                'quantity_reserved' => 0,
+                'policy' => 'deny',
+            ]);
+        });
+    }
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/ProductService.php
@@
-        InventoryItem::query()->create([
-            'store_id' => $store->getKey(),
-            'variant_id' => $variant->getKey(),
-            'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0,
-            'quantity_reserved' => 0,
-            'policy' => $variantData['inventory_policy'] ?? 'deny',
-        ]);
+        InventoryItem::withoutGlobalScopes()->updateOrCreate(
+            ['variant_id' => $variant->getKey()],
+            [
+                'store_id' => $store->getKey(),
+                'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0,
+                'quantity_reserved' => 0,
+                'policy' => $variantData['inventory_policy'] ?? 'deny',
+            ],
+        );
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/VariantMatrixService.php
@@
-        InventoryItem::query()->create([
-            'store_id' => $product->store_id,
-            'variant_id' => $variant->getKey(),
-            'quantity_on_hand' => 0,
-            'quantity_reserved' => 0,
-            'policy' => InventoryPolicy::Deny,
-        ]);
+        InventoryItem::withoutGlobalScopes()->updateOrCreate(
+            ['variant_id' => $variant->getKey()],
+            [
+                'store_id' => $product->store_id,
+                'quantity_on_hand' => 0,
+                'quantity_reserved' => 0,
+                'policy' => InventoryPolicy::Deny,
+            ],
+        );
@@
-        InventoryItem::query()->create([
-            'store_id' => $product->store_id,
-            'variant_id' => $variant->getKey(),
-            'quantity_on_hand' => 0,
-            'quantity_reserved' => 0,
-            'policy' => InventoryPolicy::Deny,
-        ]);
+        InventoryItem::withoutGlobalScopes()->updateOrCreate(
+            ['variant_id' => $variant->getKey()],
+            [
+                'store_id' => $product->store_id,
+                'quantity_on_hand' => 0,
+                'quantity_reserved' => 0,
+                'policy' => InventoryPolicy::Deny,
+            ],
+        );
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php
@@
-            InventoryItem::factory()->create([
-                'store_id' => $product->store_id,
-                'variant_id' => $variant->getKey(),
-            ]);
+            InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->update([
+                'quantity_on_hand' => 50,
+            ]);
         });
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php
@@
 use App\Enums\InventoryPolicy;
 use App\Models\ProductVariant;
 use App\Models\Store;
@@
         return [
-            'variant_id' => ProductVariant::factory(),
+            'variant_id' => fn (): int => ProductVariant::withoutEvents(
+                fn (): int => ProductVariant::factory()->create()->getKey(),
+            ),
             'store_id' => fn (array $attributes): mixed => ProductVariant::query()
                 ->with('product')
                 ->find($attributes['variant_id'])
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php
@@
     $product = Product::factory()->create(['store_id' => $store->getKey()]);
     $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
-    $item = InventoryItem::factory()->create([
-        'store_id' => $store->getKey(),
-        'variant_id' => $variant->getKey(),
-        'quantity_on_hand' => 10,
-        'quantity_reserved' => 0,
-        'policy' => 'deny',
-    ]);
+    $item = $variant->inventoryItem()->firstOrFail();
+    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php
@@
-use App\Models\InventoryItem;
 use App\Models\Product;
@@
     $variant = ProductVariant::factory()->default()->create([
         'product_id' => $product->getKey(),
         'price_amount' => 2500,
     ]);
-    InventoryItem::factory()->create([
-        'store_id' => $store->getKey(),
-        'variant_id' => $variant->getKey(),
-    ]);
     $variant->optionValues()->sync([$small->getKey(), $black->getKey()]);
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php
M /Users/fabianwesner/Herd/shop/app/Services/ProductService.php
M /Users/fabianwesner/Herd/shop/app/Services/VariantMatrixService.php
M /Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php
M /Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php

@@ -86,9 +86,11 @@

-        InventoryItem::query()->create([
-            'store_id' => $product->store_id,
-            'variant_id' => $variant->getKey(),
-            'quantity_on_hand' => 0,
-            'quantity_reserved' => 0,
-            'policy' => InventoryPolicy::Deny,
-        ]);
+        InventoryItem::withoutGlobalScopes()->updateOrCreate(
+            ['variant_id' => $variant->getKey()],
+            [
+                'store_id' => $product->store_id,
+                'quantity_on_hand' => 0,
+                'quantity_reserved' => 0,
+                'policy' => InventoryPolicy::Deny,
+            ],
+        );
     }
@@ -140,9 +142,11 @@

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

@@ -18,9 +18,4 @@
     $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]);
-    $item = InventoryItem::factory()->create([
-        'store_id' => $store->getKey(),
-        'variant_id' => $variant->getKey(),
-        'quantity_on_hand' => 10,
-        'quantity_reserved' => 0,
-        'policy' => 'deny',
-    ]);
+    $item = $variant->inventoryItem()->firstOrFail();
+    $item->forceFill(['quantity_on_hand' => 10, 'quantity_reserved' => 0, 'policy' => 'deny'])->save();

@@ -45,2 +45,25 @@

+    protected static function booted(): void
+    {
+        static::created(function (ProductVariant $variant): void {
+            if ($variant->inventoryItem()->exists()) {
+                return;
+            }
+
+            $storeId = $variant->product()->value('store_id');
+
+            if (! $storeId) {
+                return;
+            }
+
+            InventoryItem::withoutGlobalScopes()->create([
+                'store_id' => $storeId,
+                'variant_id' => $variant->getKey(),
+                'quantity_on_hand' => 0,
+                'quantity_reserved' => 0,
+                'policy' => 'deny',
+            ]);
+        });
+    }
+
     /**

@@ -165,9 +165,11 @@

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

@@ -2,3 +2,2 @@

-use App\Models\InventoryItem;
 use App\Models\Product;
@@ -30,6 +29,2 @@
         'price_amount' => 2500,
-    ]);
-    InventoryItem::factory()->create([
-        'store_id' => $store->getKey(),
-        'variant_id' => $variant->getKey(),
     ]);

@@ -74,5 +74,4 @@

-            InventoryItem::factory()->create([
-                'store_id' => $product->store_id,
-                'variant_id' => $variant->getKey(),
+            InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->update([
+                'quantity_on_hand' => 50,
             ]);

@@ -22,3 +22,5 @@
         return [
-            'variant_id' => ProductVariant::factory(),
+            'variant_id' => fn (): int => ProductVariant::withoutEvents(
+                fn (): int => ProductVariant::factory()->create()->getKey(),
+            ),
             'store_id' => fn (array $attributes): mixed => ProductVariant::query()


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Models/ProductVariant.php\nM /Users/fabianwesner/Herd/shop/app/Services/ProductService.php\nM /Users/fabianwesner/Herd/shop/app/Services/VariantMatrixService.php\nM /Users/fabianwesner/Herd/shop/database/factories/ProductFactory.php\nM /Users/fabianwesner/Herd/shop/database/factories/InventoryItemFactory.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/InventoryServiceTest.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Catalog/VariantMatrixServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}